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("/api/health", get(healthz))
834        .route("/api/version", get(api_version_handler))
835        .route("/api/openapi.yaml", get(openapi_yaml_handler))
836        .route("/llms.txt", get(llms_txt_handler))
837        .route("/llms-full.txt", get(llms_full_txt_handler))
838        .route("/badge/{metric}", get(badge_handler))
839        .route("/static/chart.js", get(chart_js_handler))
840        .route("/static/chart-report.js", get(report_chart_js_handler))
841        .route("/auth/login", get(auth::auth_login_get))
842        .route("/auth/login", post(auth::auth_login_post))
843        .route("/auth/logout", post(auth::auth_logout))
844        // Pre-access consent acknowledgement endpoint (public; exempt from the gate).
845        .route("/auth/consent", get(auth::auth_consent_accept))
846        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
847        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
848        .route(
849            "/webhooks/github",
850            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
851        )
852        .route(
853            "/webhooks/gitlab",
854            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
855        )
856        .route(
857            "/webhooks/bitbucket",
858            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
859        )
860        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
861        .layer(middleware::from_fn(consent_gate))
862        .layer(middleware::from_fn(csrf_protect))
863        .layer(middleware::from_fn_with_state(
864            state.clone(),
865            add_security_headers,
866        ))
867        .layer(build_cors_layer(state.server_mode))
868        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
869        .with_state(state)
870}
871
872/// Bearer token used by `make_test_router_server_mode()` test routers.
873/// Tests that exercise server-mode paths must include this key in their requests.
874pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
875
876/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
877/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
878/// builders below start from this and override only the fields they care about.
879///
880/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
881fn test_app_state(tmp_subdir: &str) -> AppState {
882    // Root every router in its OWN temp subdirectory. Multiple routers share a
883    // namespace prefix (e.g. "sloc_test"), so a fixed name would make parallel
884    // tests read/write the same registry.json + artifact tree and race — a
885    // concurrently-mutated shared store is what made multi_compare_* flaky.
886    // A per-call counter (plus PID, to avoid leftover-dir collisions across
887    // runs) guarantees isolation, honouring this fn's "per-test subdir" contract.
888    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
889    // FIXME: Audit that the environment access only happens in single-threaded code.
890    unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
891    let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
892    let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
893    AppState {
894        base_config: AppConfig::default(),
895        artifacts: Arc::new(Mutex::new(HashMap::new())),
896        async_runs: Arc::new(Mutex::new(HashMap::new())),
897        registry: Arc::new(Mutex::new(ScanRegistry::default())),
898        registry_path: tmp.join("registry.json"),
899        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
900        server_mode: false,
901        allow_unauthenticated: false,
902        tls_enabled: false,
903        api_keys: Arc::new(vec![]),
904        readonly_api_keys: Arc::new(vec![]),
905        rate_limiter: Arc::new(IpRateLimiter::new(
906            Duration::from_mins(1),
907            600,
908            10,
909            Duration::from_hours(1),
910        )),
911        trust_proxy: false,
912        trusted_proxy_ips: vec![],
913        git_clones_dir: tmp.join("git-clones"),
914        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
915        schedules_path: tmp.join("schedules.json"),
916        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
917        scan_profiles_path: tmp.join("scan_profiles.json"),
918        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
919        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
920        confluence_path: tmp.join("confluence_config.json"),
921        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
922        watched_dirs_path: tmp.join("watched_dirs.json"),
923        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
924        cleanup_policy_path: tmp.join("cleanup_policy.json"),
925        cleanup_task_handle: Arc::new(Mutex::new(None)),
926    }
927}
928
929/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
930pub fn make_test_router() -> Router {
931    build_router(test_app_state("sloc_test"))
932}
933
934/// Test router with one API key pre-loaded. Used by auth integration tests.
935pub fn make_test_router_with_key(api_key: &str) -> Router {
936    let mut state = test_app_state("sloc_test_key");
937    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
938    build_router(state)
939}
940
941/// Test router with a full-access key AND a read-only key.
942///
943/// Exercises the read-only credential branch in the auth middleware: a read-only
944/// key authenticates safe (GET/HEAD/OPTIONS) requests but is rejected with 403 on
945/// state-changing methods.
946pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
947    let mut state = test_app_state("sloc_test_readonly");
948    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
949    state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
950        readonly_key.to_owned(),
951    ))]);
952    build_router(state)
953}
954
955/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
956/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
957/// preview restrictions.
958pub fn make_test_router_server_mode() -> Router {
959    let mut state = test_app_state("sloc_test_server");
960    state.server_mode = true;
961    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
962        TEST_SERVER_MODE_API_KEY.to_owned(),
963    ))]);
964    build_router(state)
965}
966
967/// Server-mode test router with `allowed_scan_roots` configured.
968///
969/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
970/// success, unresolved path, and out-of-root rejection) that the empty-roots
971/// router cannot reach.
972pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
973    let mut state = test_app_state("sloc_test_server_roots");
974    state.server_mode = true;
975    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
976        TEST_SERVER_MODE_API_KEY.to_owned(),
977    ))]);
978    state.base_config.discovery.allowed_scan_roots = roots;
979    build_router(state)
980}
981
982/// Test router where the analysis semaphore is pre-exhausted (0 permits).
983/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
984pub fn make_test_router_exhausted_semaphore() -> Router {
985    let mut state = test_app_state("sloc_test_exhaust");
986    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
987    build_router(state)
988}
989
990/// Test router with a very tight rate limit (3 req/min). The third request from
991/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
992pub fn make_test_router_tight_rate_limit() -> Router {
993    let mut state = test_app_state("sloc_test_rate");
994    state.rate_limiter = Arc::new(IpRateLimiter::new(
995        Duration::from_mins(1),
996        2,
997        5,
998        Duration::from_secs(5),
999    ));
1000    build_router(state)
1001}
1002
1003/// Test router with a very tight auth lockout (threshold=2, window=200ms).
1004/// Used by tests that need to trigger and verify the auth lockout response.
1005pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1006    let mut state = test_app_state("sloc_test_auth_lockout");
1007    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1008    state.rate_limiter = Arc::new(IpRateLimiter::new(
1009        Duration::from_mins(1),
1010        600,
1011        2,                          // 2 failures triggers lockout
1012        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
1013    ));
1014    build_router(state)
1015}
1016
1017struct RuntimeSecurityConfig {
1018    api_keys: Vec<secrecy::SecretBox<String>>,
1019    readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1020    tls_cert: Option<String>,
1021    tls_key: Option<String>,
1022    tls_enabled: bool,
1023    trust_proxy: bool,
1024    trusted_proxy_ips: Vec<IpAddr>,
1025    rate_limiter: Arc<IpRateLimiter>,
1026}
1027
1028/// Whether the operator has explicitly opted into running server mode with no API key.
1029/// This is the single escape hatch for the fail-closed server-mode auth requirement.
1030fn allow_unauthenticated_server_mode() -> bool {
1031    matches!(
1032        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1033        Ok("1" | "true" | "TRUE")
1034    )
1035}
1036
1037/// Fail-closed startup gate: refuse to launch a network-facing server that has no
1038/// authentication configured, unless the operator explicitly accepted the risk.
1039/// Desktop/local mode (`server_mode == false`) is always allowed.
1040fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1041    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1042}
1043
1044/// Umbrella strict-posture switch (`SLOC_HARDENED=1`). When set, opt-in hardening
1045/// defaults take effect: transport encryption is required on non-loopback binds and
1046/// the auth-lockout threshold tightens. Off by default so existing deployments are
1047/// unaffected; individual controls also keep their own env overrides.
1048fn hardened_mode() -> bool {
1049    std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1050}
1051
1052/// Whether a certificate must be present before serving a network-facing
1053/// (non-loopback) bind. Opt-in via `SLOC_REQUIRE_TLS=1` or `SLOC_HARDENED=1`. Off by
1054/// default, so cleartext and reverse-proxy-terminated deployments keep working.
1055fn require_tls() -> bool {
1056    hardened_mode()
1057        || std::env::var("SLOC_REQUIRE_TLS")
1058            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1059}
1060
1061/// Optional sliding idle timeout for authenticated sessions. `None` (the default)
1062/// means only the 8-hour absolute cap applies — identical to prior behaviour.
1063/// `SLOC_SESSION_IDLE_SECS=<n>` sets an explicit idle limit (`0` disables); under
1064/// `SLOC_HARDENED` it defaults to 15 minutes. Each authenticated request refreshes
1065/// the session's last-seen time, so the window slides.
1066pub(crate) fn session_idle_timeout() -> Option<Duration> {
1067    match std::env::var("SLOC_SESSION_IDLE_SECS")
1068        .ok()
1069        .and_then(|v| v.parse::<u64>().ok())
1070    {
1071        Some(0) => None,
1072        Some(secs) => Some(Duration::from_secs(secs)),
1073        None if hardened_mode() => Some(Duration::from_mins(15)),
1074        None => None,
1075    }
1076}
1077
1078/// Generic authorized-use notice shown when a banner is required but the operator
1079/// has not supplied custom text via `SLOC_CONSENT_BANNER`.
1080const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1081Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1082are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1083
1084/// The pre-access consent banner text, if enabled. `SLOC_CONSENT_BANNER=<text>`
1085/// sets custom wording; `SLOC_HARDENED` alone falls back to a generic notice.
1086/// `None` (the default) disables the banner entirely.
1087fn consent_banner_text() -> Option<String> {
1088    if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1089        let t = t.trim();
1090        if !t.is_empty() {
1091            return Some(t.to_owned());
1092        }
1093    }
1094    hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1095}
1096
1097/// True when this request is a top-level browser navigation that the consent gate
1098/// should intercept. APIs, assets, webhooks, health checks, and the accept
1099/// endpoint itself are never gated.
1100fn consent_gate_applies(req: &Request<Body>) -> bool {
1101    const EXEMPT: &[&str] = &[
1102        "/auth/consent",
1103        "/static/",
1104        "/images/",
1105        "/assets/",
1106        "/badge/",
1107        "/healthz",
1108        "/api/",
1109        "/webhooks/",
1110        "/metrics",
1111        "/favicon",
1112        "/llms",
1113    ];
1114    if !matches!(
1115        *req.method(),
1116        axum::http::Method::GET | axum::http::Method::HEAD
1117    ) {
1118        return false;
1119    }
1120    let is_html = req
1121        .headers()
1122        .get(header::ACCEPT)
1123        .and_then(|v| v.to_str().ok())
1124        .is_some_and(|a| a.contains("text/html"));
1125    if !is_html {
1126        return false;
1127    }
1128    let path = req.uri().path();
1129    !EXEMPT.iter().any(|p| path.starts_with(p))
1130}
1131
1132/// Whether the request already carries the consent acknowledgement cookie.
1133fn request_has_consent(req: &Request<Body>) -> bool {
1134    req.headers()
1135        .get(header::COOKIE)
1136        .and_then(|v| v.to_str().ok())
1137        .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1138}
1139
1140/// Pre-access consent gate. When a banner is configured, browser page navigations
1141/// must acknowledge it (recorded in a session cookie) before proceeding. A no-op
1142/// when unconfigured, so default deployments are unaffected.
1143async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1144    let Some(text) = consent_banner_text() else {
1145        return next.run(req).await;
1146    };
1147    if !consent_gate_applies(&req) || request_has_consent(&req) {
1148        return next.run(req).await;
1149    }
1150    let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1151    render_consent_page(&text, next_path)
1152}
1153
1154/// Minimal escaping for embedding operator/config text into the banner HTML.
1155fn html_escape_consent(s: &str) -> String {
1156    s.replace('&', "&amp;")
1157        .replace('<', "&lt;")
1158        .replace('>', "&gt;")
1159        .replace('"', "&quot;")
1160}
1161
1162/// Render the consent interstitial with an "I Agree" action that records
1163/// acknowledgement and returns the user to where they were headed.
1164fn render_consent_page(text: &str, next_path: &str) -> Response {
1165    // Only accept a safe same-origin relative path as the return target.
1166    let safe_next = if next_path.starts_with('/')
1167        && !next_path.starts_with("//")
1168        && !next_path.contains("://")
1169        && !next_path.starts_with("/auth/")
1170    {
1171        next_path
1172    } else {
1173        "/"
1174    };
1175    let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1176    let body = format!(
1177        r#"<!doctype html><html><head><meta charset="utf-8">
1178<meta name="viewport" content="width=device-width, initial-scale=1">
1179<title>Notice and Consent — OxideSLOC</title>
1180<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1181h1{{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}}
1182.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1183.agree:hover{{background:#a04d27}}</style>
1184</head><body>
1185<h1>Notice and Consent</h1>
1186<div class="notice">{}</div>
1187<a class="agree" href="{}">I Agree</a>
1188</body></html>"#,
1189        html_escape_consent(text),
1190        accept_url
1191    );
1192    (StatusCode::OK, Html(body)).into_response()
1193}
1194
1195/// Emit operator-facing warnings for insecure server-mode configurations.
1196/// Pure side-effect (stdout); no bearing on the returned config values.
1197// The bools are independent configuration facts read from the resolved config, not
1198// a mode enum — folding them into a struct just to pass them here would add
1199// ceremony without clarity. Scope the allow to this diagnostic helper.
1200#[allow(clippy::fn_params_excessive_bools)]
1201fn emit_server_mode_warnings(
1202    server_mode: bool,
1203    api_keys_empty: bool,
1204    tls_enabled: bool,
1205    trust_proxy: bool,
1206    trusted_proxy_ips: &[IpAddr],
1207) {
1208    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1209        // Absence of a key is a hard startup failure in server mode (enforced by the
1210        // caller, `serve`). The only exception is an explicit operator opt-in via
1211        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
1212        println!(
1213            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1214             authentication. Every web endpoint is publicly reachable. Do NOT use this \
1215             outside a trusted, isolated network."
1216        );
1217    }
1218    if server_mode && !tls_enabled {
1219        println!(
1220            "WARNING: TLS is not configured. Traffic is cleartext. \
1221             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1222             or terminate TLS at a reverse proxy (nginx, caddy)."
1223        );
1224    }
1225    if server_mode {
1226        println!(
1227            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1228             to restrict cross-origin access (comma-separated)."
1229        );
1230    }
1231    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1232    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1233        println!(
1234            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1235             DISABLED for all git operations. Remove this variable before production use."
1236        );
1237    }
1238}
1239
1240/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
1241fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1242    if trust_proxy {
1243        if trusted_proxy_ips.is_empty() {
1244            println!(
1245                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1246                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1247                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1248            );
1249        } else {
1250            println!(
1251                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1252                trusted_proxy_ips
1253                    .iter()
1254                    .map(std::string::ToString::to_string)
1255                    .collect::<Vec<_>>()
1256                    .join(", ")
1257            );
1258        }
1259    } else if server_mode {
1260        println!(
1261            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1262             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1263             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1264             enable per-client rate limiting via X-Forwarded-For."
1265        );
1266    }
1267}
1268
1269fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1270    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1271        .or_else(|_| std::env::var("SLOC_API_KEY"))
1272        .unwrap_or_default()
1273        .split(',')
1274        .map(str::trim)
1275        .filter(|s| !s.is_empty())
1276        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1277        .collect();
1278    let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1279        std::env::var("SLOC_API_KEYS_READONLY")
1280            .unwrap_or_default()
1281            .split(',')
1282            .map(str::trim)
1283            .filter(|s| !s.is_empty())
1284            .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1285            .collect();
1286    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1287    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1288    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1289    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1290    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1291        .unwrap_or_default()
1292        .split(',')
1293        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1294        .collect();
1295    emit_server_mode_warnings(
1296        server_mode,
1297        api_keys.is_empty(),
1298        tls_enabled,
1299        trust_proxy,
1300        &trusted_proxy_ips,
1301    );
1302    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1303        .ok()
1304        .and_then(|v| v.parse::<u32>().ok())
1305        .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1306    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1307        .ok()
1308        .and_then(|v| v.parse::<u64>().ok())
1309        .unwrap_or(3600);
1310    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1311    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1312    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1313    let default_rpm: usize = if server_mode { 120 } else { 600 };
1314    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1315        .ok()
1316        .and_then(|v| v.parse::<usize>().ok())
1317        .unwrap_or(default_rpm);
1318    let rate_limiter = Arc::new(IpRateLimiter::new(
1319        Duration::from_mins(1),
1320        rate_limit_rpm,
1321        auth_lockout_threshold,
1322        Duration::from_secs(auth_lockout_secs),
1323    ));
1324    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1325    RuntimeSecurityConfig {
1326        api_keys,
1327        readonly_api_keys,
1328        tls_cert,
1329        tls_key,
1330        tls_enabled,
1331        trust_proxy,
1332        trusted_proxy_ips,
1333        rate_limiter,
1334    }
1335}
1336
1337/// # Errors
1338///
1339/// Returns an error if the server fails to bind to the configured address or
1340/// if the TLS configuration cannot be loaded.
1341///
1342/// # Panics
1343///
1344/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1345#[allow(clippy::too_many_lines)]
1346pub async fn serve(config: AppConfig) -> Result<()> {
1347    let bind_address = config.web.bind_address.clone();
1348    let server_mode = config.web.server_mode;
1349    let output_root = resolve_output_root(None);
1350    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1351    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1352        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1353    let mut registry = ScanRegistry::load(&registry_path);
1354    registry.prune_stale();
1355    let _ = registry.save(&registry_path);
1356
1357    let sec = load_runtime_security_config(server_mode);
1358    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
1359    // launch with no API key would expose every endpoint publicly; fail closed unless the
1360    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
1361    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1362        audit::record(
1363            "server_start_refused",
1364            "denied",
1365            &[(
1366                "reason",
1367                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1368            )],
1369        );
1370        anyhow::bail!(
1371            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1372             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1373             unauthenticated server on a trusted, isolated network, explicitly set \
1374             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1375        );
1376    }
1377    if server_mode && sec.api_keys.is_empty() {
1378        audit::record("server_start_unauthenticated", "warning", &[]);
1379    }
1380    spawn_upload_staging_cleanup();
1381
1382    let git_clones_dir = resolve_git_clones_dir(&output_root);
1383    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1384        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1385    let schedules = ScheduleStore::load(&schedules_path);
1386    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1387        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1388    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1389    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1390        |_| output_root.join("confluence_config.json"),
1391        PathBuf::from,
1392    );
1393    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1394    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1395        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1396    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1397    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1398        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1399    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1400
1401    let state = AppState {
1402        base_config: config,
1403        artifacts: Arc::new(Mutex::new(HashMap::new())),
1404        async_runs: Arc::new(Mutex::new(HashMap::new())),
1405        registry: Arc::new(Mutex::new(registry)),
1406        registry_path,
1407        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1408        server_mode,
1409        allow_unauthenticated: allow_unauthenticated_server_mode(),
1410        tls_enabled: sec.tls_enabled,
1411        api_keys: Arc::new(sec.api_keys),
1412        readonly_api_keys: Arc::new(sec.readonly_api_keys),
1413        rate_limiter: sec.rate_limiter,
1414        trust_proxy: sec.trust_proxy,
1415        trusted_proxy_ips: sec.trusted_proxy_ips,
1416        git_clones_dir,
1417        schedules: Arc::new(Mutex::new(schedules)),
1418        schedules_path,
1419        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1420        scan_profiles_path,
1421        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1422        confluence: Arc::new(Mutex::new(confluence)),
1423        confluence_path,
1424        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1425        watched_dirs_path,
1426        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1427        cleanup_policy_path,
1428        cleanup_task_handle: Arc::new(Mutex::new(None)),
1429    };
1430
1431    restart_poll_schedules(&state).await;
1432    warn_insecure_gitlab_webhooks(&state).await;
1433
1434    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1435    {
1436        let enabled = state
1437            .cleanup_policy
1438            .lock()
1439            .await
1440            .policy
1441            .as_ref()
1442            .is_some_and(|p| p.enabled);
1443        if enabled {
1444            let handle = spawn_cleanup_policy_task(state.clone());
1445            *state.cleanup_task_handle.lock().await = Some(handle);
1446        }
1447    }
1448
1449    let app = build_router(state.clone());
1450
1451    // Try the configured port first, then step up through a few alternatives.
1452    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1453    // kernel zombie (visible in netstat but owned by no living process).  Rather
1454    // than failing, we auto-select the next free port and tell the user.
1455    let preferred: SocketAddr = bind_address
1456        .parse()
1457        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1458
1459    // Opt-in transport-encryption gate: refuse to expose a network-facing (non-
1460    // loopback) listener in cleartext when TLS enforcement is requested. Off by
1461    // default; enable with SLOC_REQUIRE_TLS=1 or SLOC_HARDENED=1. Loopback binds
1462    // (including reverse-proxy-terminated setups) are always allowed.
1463    if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1464        audit::record(
1465            "server_start_refused",
1466            "denied",
1467            &[("reason", "TLS required for non-loopback bind")],
1468        );
1469        anyhow::bail!(
1470            "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1471             SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1472             a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1473        );
1474    }
1475
1476    let (listener, addr) = {
1477        let candidates = (0u16..=9).map(|offset| {
1478            let mut a = preferred;
1479            a.set_port(preferred.port().saturating_add(offset));
1480            a
1481        });
1482        let mut found = None;
1483        for candidate in candidates {
1484            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1485                found = Some((l, candidate));
1486                break;
1487            }
1488        }
1489        found.ok_or_else(|| {
1490            anyhow::anyhow!(
1491                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1492                bind_address,
1493                preferred.port(),
1494                preferred.port().saturating_add(9)
1495            )
1496        })?
1497    };
1498    if addr != preferred {
1499        eprintln!(
1500            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1501             using {} instead.",
1502            preferred.port(),
1503            addr.port()
1504        );
1505    }
1506
1507    if sec.tls_enabled {
1508        let cert_path = sec
1509            .tls_cert
1510            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1511        let key_path = sec
1512            .tls_key
1513            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1514        let tls_config = build_tls_config(&cert_path, &key_path)
1515            .context("failed to load TLS certificate/key")?;
1516        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1517
1518        let url = format!("https://{addr}/");
1519        println!("OxideSLOC server running at {url} (TLS)");
1520        if let Some(lan) = wildcard_lan_url(&url) {
1521            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1522        }
1523        println!("Use Ctrl+C to stop.");
1524
1525        return serve_tls(listener, app, acceptor, server_mode).await;
1526    }
1527
1528    let url = format!("http://{addr}/");
1529    log_startup_url(&url, server_mode);
1530
1531    axum::serve(
1532        listener,
1533        app.into_make_service_with_connect_info::<SocketAddr>(),
1534    )
1535    .with_graceful_shutdown(shutdown_signal(server_mode))
1536    .await
1537    .context("web server terminated unexpectedly")
1538}
1539
1540/// Discover the primary non-loopback IPv4 address by asking the OS which
1541/// outbound interface it would use to reach a public address.  No packets are
1542/// sent — the UDP socket is only used to query the routing table.
1543fn primary_lan_ip() -> Option<String> {
1544    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1545    socket.connect("8.8.8.8:80").ok()?;
1546    let addr = socket.local_addr().ok()?;
1547    let ip = addr.ip();
1548    if ip.is_loopback() {
1549        return None;
1550    }
1551    Some(ip.to_string())
1552}
1553
1554/// If `url` binds a wildcard address (`0.0.0.0` or `[::]`), return the same URL
1555/// with the primary LAN IP substituted, so the startup log shows a client-usable
1556/// address alongside the bind address. Returns `None` for concrete binds or when
1557/// no routable LAN address can be determined (e.g. loopback-only / no default route).
1558fn wildcard_lan_url(url: &str) -> Option<String> {
1559    if url.contains("0.0.0.0") {
1560        primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1561    } else if url.contains("[::]") {
1562        primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1563    } else {
1564        None
1565    }
1566}
1567
1568/// Print the startup URL and, in local mode, open the browser and schedule it.
1569fn log_startup_url(url: &str, server_mode: bool) {
1570    if server_mode {
1571        println!("OxideSLOC server running at {url}");
1572        if let Some(lan) = wildcard_lan_url(url) {
1573            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1574        }
1575        println!("Use Ctrl+C to stop.");
1576    } else {
1577        println!("OxideSLOC local web UI running at {url}");
1578        println!("Press Ctrl+C to stop the server.");
1579        let open_url = url.to_owned();
1580        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1581    }
1582}
1583
1584/// Open the given URL in the default system browser.
1585fn open_browser_tab(url: &str) {
1586    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1587    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1588    // first quoted token as a window title — both are fragile and shell-parsed. The
1589    // url.dll handler receives the URL as a single, non-shell argument.
1590    #[cfg(target_os = "windows")]
1591    let _ = std::process::Command::new("rundll32")
1592        .args(["url.dll,FileProtocolHandler", url])
1593        .stdout(Stdio::null())
1594        .stderr(Stdio::null())
1595        .spawn();
1596    #[cfg(target_os = "macos")]
1597    let _ = std::process::Command::new("open")
1598        .arg(url)
1599        .stdout(Stdio::null())
1600        .stderr(Stdio::null())
1601        .spawn();
1602    #[cfg(target_os = "linux")]
1603    let _ = std::process::Command::new("xdg-open")
1604        .arg(url)
1605        .stdout(Stdio::null())
1606        .stderr(Stdio::null())
1607        .spawn();
1608}
1609
1610/// Graceful-shutdown future: resolves on Ctrl-C.
1611async fn shutdown_signal(server_mode: bool) {
1612    if tokio::signal::ctrl_c().await.is_ok() {
1613        println!();
1614        if server_mode {
1615            println!("Shutting down OxideSLOC server...");
1616        } else {
1617            println!("Shutting down OxideSLOC local web UI...");
1618        }
1619        println!("Server stopped cleanly.");
1620    }
1621}
1622
1623/// Load a rustls `ServerConfig` from PEM certificate and key files.
1624fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1625    use rustls_pki_types::pem::PemObject;
1626    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1627
1628    let cert_bytes =
1629        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1630    let key_bytes =
1631        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1632
1633    let cert_chain: Vec<CertificateDer<'static>> =
1634        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1635            .collect::<std::result::Result<_, _>>()
1636            .context("failed to parse TLS certificates")?;
1637
1638    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1639        .context("failed to parse TLS private key")?;
1640
1641    // Explicitly pin the accepted protocol versions to TLS 1.2 and 1.3 (these are
1642    // rustls's safe defaults; stated here so the accepted set is auditable). rustls
1643    // ships only modern AEAD cipher suites — no CBC/RC4/3DES — so no suite pinning is
1644    // needed to exclude weak ciphers.
1645    let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1646        &rustls::version::TLS13,
1647        &rustls::version::TLS12,
1648    ]);
1649
1650    // Opt-in mutual TLS: when SLOC_TLS_CLIENT_CA points to a PEM CA bundle, require
1651    // every client to present a certificate that chains to it — a transport-layer
1652    // factor on top of the application API key. Unset = no client auth (prior
1653    // behaviour).
1654    let config = match client_cert_verifier()? {
1655        Some(verifier) => builder
1656            .with_client_cert_verifier(verifier)
1657            .with_single_cert(cert_chain, key),
1658        None => builder
1659            .with_no_client_auth()
1660            .with_single_cert(cert_chain, key),
1661    };
1662    config.context("failed to build TLS server config")
1663}
1664
1665/// Build a client-certificate verifier when `SLOC_TLS_CLIENT_CA` is configured,
1666/// enabling mutual TLS. Returns `None` (no client auth) when unset — the default.
1667fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1668    use rustls_pki_types::CertificateDer;
1669    use rustls_pki_types::pem::PemObject;
1670
1671    let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1672        .ok()
1673        .filter(|s| !s.is_empty())
1674    else {
1675        return Ok(None);
1676    };
1677    let ca_bytes = fs::read(&ca_path)
1678        .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1679    let mut roots = rustls::RootCertStore::empty();
1680    for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1681        let cert = cert.context("failed to parse client CA certificate")?;
1682        roots
1683            .add(cert)
1684            .context("failed to add client CA certificate to root store")?;
1685    }
1686    let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1687        .build()
1688        .context("failed to build client certificate verifier")?;
1689    Ok(Some(verifier))
1690}
1691
1692/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1693async fn serve_tls(
1694    listener: tokio::net::TcpListener,
1695    app: Router,
1696    acceptor: tokio_rustls::TlsAcceptor,
1697    server_mode: bool,
1698) -> Result<()> {
1699    use hyper_util::rt::{TokioExecutor, TokioIo};
1700    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1701    use hyper_util::service::TowerToHyperService;
1702    use tower::{Service, ServiceExt};
1703
1704    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1705
1706    loop {
1707        tokio::select! {
1708            biased;
1709            _ = tokio::signal::ctrl_c() => {
1710                println!();
1711                if server_mode {
1712                    println!("Shutting down OxideSLOC server...");
1713                } else {
1714                    println!("Shutting down OxideSLOC local web UI...");
1715                }
1716                println!("Server stopped cleanly.");
1717                return Ok(());
1718            }
1719            result = listener.accept() => {
1720                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1721                let acceptor = acceptor.clone();
1722                let mut factory = make_svc.clone();
1723
1724                tokio::spawn(async move {
1725                    let tls = match acceptor.accept(tcp).await {
1726                        Ok(s) => s,
1727                        Err(e) => {
1728                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1729                            return;
1730                        }
1731                    };
1732                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1733                        Ok(f) => match Service::call(f, peer_addr).await {
1734                            Ok(s) => s,
1735                            Err(_) => return,
1736                        },
1737                        Err(_) => return,
1738                    };
1739                    let io = TokioIo::new(tls);
1740                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1741                        .serve_connection(io, TowerToHyperService::new(svc))
1742                        .await
1743                    {
1744                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1745                    }
1746                });
1747            }
1748        }
1749    }
1750}
1751
1752// auth moved to auth.rs
1753
1754fn build_cors_layer(server_mode: bool) -> CorsLayer {
1755    if server_mode {
1756        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1757            .unwrap_or_default()
1758            .split(',')
1759            .filter(|s| !s.is_empty())
1760            .filter_map(|s| s.trim().parse().ok())
1761            .collect();
1762        if allowed.is_empty() {
1763            return CorsLayer::new();
1764        }
1765        CorsLayer::new()
1766            .allow_origin(AllowOrigin::list(allowed))
1767            .allow_methods(AllowMethods::list([
1768                axum::http::Method::GET,
1769                axum::http::Method::POST,
1770            ]))
1771            .allow_headers(AllowHeaders::list([
1772                axum::http::header::AUTHORIZATION,
1773                axum::http::header::CONTENT_TYPE,
1774            ]))
1775    } else {
1776        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1777            let s = origin.to_str().unwrap_or("");
1778            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1779        }))
1780    }
1781}
1782
1783async fn add_security_headers(
1784    State(state): State<AppState>,
1785    mut req: Request<Body>,
1786    next: Next,
1787) -> Response {
1788    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1789    req.extensions_mut().insert(CspNonce(nonce.clone()));
1790    let mut resp = next.run(req).await;
1791    inject_page_fade_into_html(&mut resp, &nonce).await;
1792    let h = resp.headers_mut();
1793    // frame-ancestors defaults to deny (the UI cannot be iframed anywhere). An
1794    // operator can opt into embedding in named corporate dashboards by setting
1795    // SLOC_FRAME_ANCESTORS to a space-separated origin allowlist. X-Frame-Options
1796    // cannot express a multi-origin allowlist, so when one is configured we drop
1797    // XFO and let the CSP frame-ancestors directive govern (per-origin, and what
1798    // modern browsers honour); unset keeps the strict XFO: DENY + frame-ancestors
1799    // 'none' posture. A malformed value falls back to the safe default below.
1800    let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1801        .ok()
1802        .map(|v| v.trim().to_string())
1803        .filter(|v| !v.is_empty());
1804    if frame_ancestors.is_none() {
1805        h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1806    }
1807    let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1808    h.insert(
1809        "X-Content-Type-Options",
1810        HeaderValue::from_static("nosniff"),
1811    );
1812    h.insert(
1813        "Referrer-Policy",
1814        HeaderValue::from_static("strict-origin-when-cross-origin"),
1815    );
1816    let csp = format!(
1817        "default-src 'self'; \
1818         base-uri 'self'; \
1819         form-action 'self'; \
1820         style-src 'self' 'unsafe-inline'; \
1821         img-src 'self' data: blob:; \
1822         script-src 'self' 'nonce-{nonce}'; \
1823         font-src 'self' data:; \
1824         object-src 'none'; \
1825         frame-ancestors {frame_ancestors_directive}"
1826    );
1827    h.insert(
1828        "Content-Security-Policy",
1829        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1830            HeaderValue::from_static(
1831                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1832            )
1833        }),
1834    );
1835    h.insert(
1836        "X-Permitted-Cross-Domain-Policies",
1837        HeaderValue::from_static("none"),
1838    );
1839    h.insert(
1840        "Permissions-Policy",
1841        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1842    );
1843    h.insert(
1844        "Cross-Origin-Opener-Policy",
1845        HeaderValue::from_static("same-origin"),
1846    );
1847    h.insert(
1848        "Cross-Origin-Resource-Policy",
1849        HeaderValue::from_static("same-origin"),
1850    );
1851    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1852    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1853    h.insert(
1854        "Cross-Origin-Embedder-Policy",
1855        HeaderValue::from_static("require-corp"),
1856    );
1857    if state.tls_enabled {
1858        h.insert(
1859            "Strict-Transport-Security",
1860            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1861        );
1862    }
1863    resp
1864}
1865
1866/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1867///
1868/// On state-changing methods, browser-driven cookie-authenticated requests must
1869/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1870/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1871///
1872/// Deliberately exempt:
1873/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1874/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1875///   ambient, so it is not CSRF-exploitable.
1876/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1877/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1878///   a browser performing a CSRF attack always sends `Origin`.
1879async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1880    use axum::http::Method;
1881
1882    let is_state_changing = matches!(
1883        *req.method(),
1884        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1885    );
1886    let path = req.uri().path();
1887    let has_token_auth = req.headers().contains_key("X-API-Key")
1888        || req
1889            .headers()
1890            .get(header::AUTHORIZATION)
1891            .and_then(|v| v.to_str().ok())
1892            .is_some_and(|v| v.starts_with("Bearer "));
1893
1894    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1895        return next.run(req).await;
1896    }
1897
1898    let headers = req.headers();
1899    let header_str = |name: &header::HeaderName| {
1900        headers
1901            .get(name)
1902            .and_then(|v| v.to_str().ok())
1903            .map(str::to_owned)
1904    };
1905    let origin = header_str(&header::ORIGIN);
1906    let referer = header_str(&header::REFERER);
1907    let host = header_str(&header::HOST);
1908
1909    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1910    let authority_of = |url: &str| -> Option<String> {
1911        url.split_once("://")
1912            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1913    };
1914
1915    let source_authority = origin
1916        .as_deref()
1917        .and_then(authority_of)
1918        .or_else(|| referer.as_deref().and_then(authority_of));
1919
1920    match (source_authority, host) {
1921        // Neither Origin nor Referer present: treat as a non-browser client.
1922        (None, _) => next.run(req).await,
1923        (Some(src), Some(h)) if src == h => next.run(req).await,
1924        (Some(src), host) => {
1925            tracing::warn!(
1926                event = "csrf_rejected",
1927                path = %path,
1928                origin = %src,
1929                host = ?host,
1930                "Cross-origin state-changing request rejected (CSRF guard)"
1931            );
1932            (
1933                StatusCode::FORBIDDEN,
1934                "403 Forbidden — cross-origin request rejected\n",
1935            )
1936                .into_response()
1937        }
1938    }
1939}
1940
1941/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1942/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1943/// is overkill — a short opacity fade gives a smooth page-to-page transition
1944/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1945/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1946/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1947/// light-mode flash for dark-theme users.
1948fn page_fade_html(nonce: &str) -> String {
1949    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1950    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1951    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1952    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1953    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1954    // through the entire body parse, which reads as a delay before navigation "begins"
1955    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1956    // 0 -> 1 cleanly, with no pre-paint hold.
1957    const STYLE: &str = r"<style>
1958@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1959.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1960body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1961@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1962</style>";
1963    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1964    // The click handler gives immediate feedback by fading the *content* out the moment a
1965    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1966    // preventDefault or delay navigation — the browser navigates instantly and the fade
1967    // plays opportunistically during the natural fetch window, so no latency is added.
1968    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1969    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1970    // if the click was actually a download (no unload) or the page is restored from bfcache.
1971    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');});})();";
1972    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1973}
1974
1975/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1976/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1977/// `<script>` — meant to be spliced in immediately after `<body>`.
1978///
1979/// It pairs the spinner with a **visibility gate**: from the first byte the page
1980/// content is held at `visibility:hidden` (only the overlay paints), so the user
1981/// never sees a half-rendered flash while charts/tables are still settling. On
1982/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1983/// still-opaque overlay, which then fades out one frame later — so the reveal is
1984/// of a finished page, with no glitch on either side of the transition.
1985///
1986/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1987/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1988/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1989fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1990    const TPL: &str = r#"<style nonce="__N__">
1991html.sloc-pending body{visibility:hidden;}
1992html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1993#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%);}
1994#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1995body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1996body.pdf-mode #rpt-loading-overlay{display:none!important;}
1997.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1998.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;}
1999.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;}
2000@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
2001@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
2002body.dark-theme .rpt-bg-blob{opacity:.36;}
2003.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;}
2004@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
2005body.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);}
2006.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
2007.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2008.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2009.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));}
2010@keyframes rpt-spin{to{transform:rotate(360deg);}}
2011.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;}
2012body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2013body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2014.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2015.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;}
2016@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2017.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2018.rpt-dot:nth-child(2){animation-delay:.28s;}
2019.rpt-dot:nth-child(3){animation-delay:.56s;}
2020@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2021.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2022.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2023.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;}
2024body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2025@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;}}
2026</style>
2027<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2028<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>
2029<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2030  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2031  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2032  <div class="rpt-load-card">
2033    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2034    <div class="rpt-spinner-wrap">
2035      <div class="rpt-spinner-track"></div>
2036      <div class="rpt-spinner"></div>
2037      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2038    </div>
2039    <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>
2040    <div class="rpt-status" id="rpt-status">__LABEL__</div>
2041    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2042  </div>
2043</div>
2044<script nonce="__N__">
2045(function(){
2046  var ov=document.getElementById('rpt-loading-overlay');
2047  var root=document.documentElement;
2048  function reveal(){root.classList.remove('sloc-pending');}
2049  if(!ov){reveal();return;}
2050  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2051  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2052  var mi=0,prog=0,done=false,start=Date.now();
2053  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2054  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2055  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2056  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2057  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2058  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2059  setProg(8);
2060  var msgTimer=setInterval(nextMsg,700);
2061  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);
2062  // These pages draw charts into known SVG containers that start empty and are
2063  // filled by JS once layout is available (some only after a ResizeObserver pass
2064  // post-`load`). Treat the page as ready only once every chart container present
2065  // actually has rendered content, so the overlay never lifts on a half-drawn page.
2066  function chartsRendered(){
2067    var sel=['#cmp-tl-svg','#mc-chart'];
2068    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2069    return true;
2070  }
2071  function finish(){
2072    if(done)return;done=true;
2073    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2074    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2075    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2076    reveal();
2077    requestAnimationFrame(function(){requestAnimationFrame(function(){
2078      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2079    });});
2080  }
2081  // Wait for `load` (resources + first layout), then poll until the charts have
2082  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2083  function afterLoad(){
2084    var loadAt=Date.now();
2085    (function poll(){
2086      if(done)return;
2087      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2088        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2089        return;
2090      }
2091      requestAnimationFrame(poll);
2092    })();
2093  }
2094  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2095  // Absolute safety net: never let the gate/overlay get stuck.
2096  setTimeout(function(){if(!done)finish();},HARD_CAP);
2097})();
2098</script>"#;
2099    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2100}
2101
2102/// Shared toast-notification assets + a global PDF-export helper, spliced into
2103/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
2104/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
2105/// placed just before `</body>`.
2106///
2107/// It defines two globals:
2108/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
2109///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
2110///   toast stays up until its returned handle's `.dismiss()` is called.
2111/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
2112///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
2113///   `/export/pdf`, triggers the download, then raises a success or error toast and
2114///   restores the button. Centralising this guarantees identical, obvious feedback
2115///   everywhere instead of a silent `alert()`-only failure path.
2116fn sloc_toast_assets(nonce: &str) -> String {
2117    const TPL: &str = r#"<style nonce="__N__">
2118#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;}
2119.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);}
2120.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2121.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2122.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;}
2123.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2124.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2125.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2126.sloc-toast-success{border-color:#bfe0cc;}
2127.sloc-toast-error{border-color:#e6b3b3;}
2128.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2129.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;}
2130@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2131.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;}
2132.sloc-toast-x:hover{opacity:1;}
2133body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2134body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2135body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2136body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2137@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2138</style>
2139<script nonce="__N__">
2140(function(){
2141  if(window.slocToast)return;
2142  function wrap(){
2143    var w=document.getElementById('sloc-toast-wrap');
2144    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);}
2145    return w;
2146  }
2147  window.slocToast=function(msg,opts){
2148    opts=opts||{};
2149    var type=opts.type||'info';
2150    var loading=type==='loading';
2151    var t=document.createElement('div');
2152    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2153    t.setAttribute('role',type==='error'?'alert':'status');
2154    var ico=loading
2155      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2156      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2157    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2158    t.querySelector('.sloc-toast-msg').textContent=String(msg);
2159    wrap().appendChild(t);
2160    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2161    var gone=false,timer=null;
2162    function close(){
2163      if(gone)return;gone=true;if(timer)clearTimeout(timer);
2164      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2165      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2166    }
2167    t.querySelector('.sloc-toast-x').addEventListener('click',close);
2168    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2169    if(ttl>0)timer=setTimeout(close,ttl);
2170    return {dismiss:close,el:t};
2171  };
2172  window.slocExportPdf=function(o){
2173    o=o||{};
2174    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2175    if(btn&&btn.disabled)return;
2176    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2177    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2178    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2179      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2180      .then(function(blob){
2181        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2182        document.body.appendChild(a);a.click();document.body.removeChild(a);
2183        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2184        load.dismiss();
2185        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2186      })
2187      .catch(function(e){
2188        load.dismiss();
2189        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2190      })
2191      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2192  };
2193})();
2194</script>"#;
2195    TPL.replace("__N__", nonce)
2196}
2197
2198/// Buffer an HTML response body and splice the page fade-in right after the
2199/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
2200/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
2201/// branded loading spinner for slow renders).
2202async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2203    let is_html = resp
2204        .headers()
2205        .get(header::CONTENT_TYPE)
2206        .and_then(|v| v.to_str().ok())
2207        .is_some_and(|v| v.starts_with("text/html"));
2208    if !is_html {
2209        return;
2210    }
2211    let body = std::mem::replace(resp.body_mut(), Body::empty());
2212    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2213        return;
2214    };
2215    let html = match String::from_utf8(bytes.to_vec()) {
2216        Ok(s) => s,
2217        Err(e) => {
2218            *resp.body_mut() = Body::from(e.into_bytes());
2219            return;
2220        }
2221    };
2222    if html.contains("id=\"rpt-loading-overlay\"") {
2223        *resp.body_mut() = Body::from(html);
2224        return;
2225    }
2226    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
2227    // avoids allocating a lowercased copy of the whole document on every request.
2228    // Fall back to a case-insensitive scan only if that fails (rare/never).
2229    let insert_at = html
2230        .find("<body")
2231        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2232        .or_else(|| {
2233            let lower = html.to_ascii_lowercase();
2234            lower
2235                .find("<body")
2236                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2237        });
2238    let new_html = match insert_at {
2239        Some(at) => {
2240            let mut out = String::with_capacity(html.len() + 1024);
2241            out.push_str(&html[..at]);
2242            out.push_str(&page_fade_html(nonce));
2243            out.push_str(&html[at..]);
2244            out
2245        }
2246        None => html,
2247    };
2248    resp.headers_mut().remove(header::CONTENT_LENGTH);
2249    *resp.body_mut() = Body::from(new_html);
2250}
2251
2252async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2253    let peer_ip = req
2254        .extensions()
2255        .get::<axum::extract::ConnectInfo<SocketAddr>>()
2256        .map(|c| c.0.ip());
2257
2258    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
2259    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
2260    // header spoofing from direct connections.
2261    let ip = peer_ip
2262        .and_then(|peer| {
2263            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2264                req.headers()
2265                    .get("X-Forwarded-For")
2266                    .and_then(|v| v.to_str().ok())
2267                    .and_then(|s| s.split(',').next())
2268                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
2269            } else {
2270                None
2271            }
2272        })
2273        .or(peer_ip)
2274        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2275
2276    if !state.rate_limiter.is_allowed(ip) {
2277        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2278            path = %req.uri().path(), "Rate limit exceeded");
2279        return (
2280            StatusCode::TOO_MANY_REQUESTS,
2281            [(header::RETRY_AFTER, "60")],
2282            "429 Too Many Requests\n",
2283        )
2284            .into_response();
2285    }
2286    next.run(req).await
2287}
2288
2289async fn splash(
2290    State(state): State<AppState>,
2291    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2292) -> impl IntoResponse {
2293    let lan_ip = if state.server_mode {
2294        primary_lan_ip()
2295    } else {
2296        None
2297    };
2298    let port = state
2299        .base_config
2300        .web
2301        .bind_address
2302        .rsplit(':')
2303        .next()
2304        .and_then(|p| p.parse::<u16>().ok())
2305        .unwrap_or(4317);
2306    let has_api_key = !state.api_keys.is_empty();
2307    let template = SplashTemplate {
2308        csp_nonce,
2309        server_mode: state.server_mode,
2310        lan_ip,
2311        port,
2312        version: env!("CARGO_PKG_VERSION"),
2313        has_api_key,
2314    };
2315    Html(
2316        template
2317            .render()
2318            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2319    )
2320}
2321
2322async fn index(
2323    State(state): State<AppState>,
2324    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2325    Query(query): Query<IndexQuery>,
2326) -> impl IntoResponse {
2327    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2328        let policy = query
2329            .mixed_line_policy
2330            .unwrap_or_else(|| "code_only".to_string());
2331        let behavior = query
2332            .binary_file_behavior
2333            .unwrap_or_else(|| "skip".to_string());
2334        let cfg = ScanConfig {
2335            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2336            path: query.path.unwrap_or_default(),
2337            include_globs: query.include_globs.unwrap_or_default(),
2338            exclude_globs: query.exclude_globs.unwrap_or_default(),
2339            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2340            mixed_line_policy: policy,
2341            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2342                != Some("off"),
2343            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2344            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2345            vendor_directory_detection: query.vendor_directory_detection.as_deref()
2346                != Some("disabled"),
2347            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2348            binary_file_behavior: behavior,
2349            output_dir: query.output_dir.unwrap_or_default(),
2350            report_title: query.report_title.unwrap_or_default(),
2351            continuation_line_policy: query
2352                .continuation_line_policy
2353                .unwrap_or_else(default_each_physical_line),
2354            blank_in_block_comment_policy: query
2355                .blank_in_block_comment_policy
2356                .unwrap_or_else(default_count_as_comment),
2357            count_compiler_directives: query.count_compiler_directives.as_deref()
2358                != Some("disabled"),
2359            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2360            style_col_threshold: query
2361                .style_col_threshold
2362                .as_deref()
2363                .and_then(|s| s.parse().ok())
2364                .unwrap_or(80),
2365            style_score_threshold: query
2366                .style_score_threshold
2367                .as_deref()
2368                .and_then(|s| s.parse().ok())
2369                .unwrap_or(0),
2370            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2371            coverage_file: query.coverage_file.unwrap_or_default(),
2372            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2373            complexity_alert: query
2374                .complexity_alert
2375                .as_deref()
2376                .and_then(|s| s.parse().ok())
2377                .unwrap_or(0),
2378            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2379            activity_window: query
2380                .activity_window
2381                .as_deref()
2382                .and_then(|s| s.parse().ok())
2383                .unwrap_or(90),
2384        };
2385        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2386    } else {
2387        "{}".to_string()
2388    };
2389
2390    let git_repo = query.git_repo.unwrap_or_default();
2391    let git_ref = query.git_ref.unwrap_or_default();
2392
2393    let git_label = make_git_label(&git_repo, &git_ref);
2394    let git_output_dir = if git_label.is_empty() {
2395        String::new()
2396    } else {
2397        desktop_dir().join(&git_label).display().to_string()
2398    };
2399    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2400    let git_output_dir_json =
2401        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2402
2403    let template = IndexTemplate {
2404        version: env!("CARGO_PKG_VERSION"),
2405        prefill_json,
2406        csp_nonce,
2407        git_repo,
2408        git_ref,
2409        git_label_json,
2410        git_output_dir_json,
2411        server_mode: state.server_mode,
2412    };
2413
2414    Html(
2415        template
2416            .render()
2417            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2418    )
2419}
2420
2421async fn scan_setup_handler(
2422    State(state): State<AppState>,
2423    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2424) -> impl IntoResponse {
2425    let recent_scans_json = {
2426        let arr: Vec<serde_json::Value> = {
2427            let reg = state.registry.lock().await;
2428            reg.entries
2429                .iter()
2430                .rev()
2431                .take(6)
2432                .map(|e| {
2433                    let run_dir = e
2434                        .html_path
2435                        .as_ref()
2436                        .or(e.json_path.as_ref())
2437                        .and_then(|p| p.parent().map(PathBuf::from));
2438                    let config_val: Option<serde_json::Value> = run_dir
2439                        .and_then(|d| find_scan_config_in_dir(&d))
2440                        .and_then(|p| fs::read_to_string(&p).ok())
2441                        .and_then(|s| serde_json::from_str(&s).ok());
2442                    serde_json::json!({
2443                        "project_label": e.project_label,
2444                        "timestamp": fmt_la_time(e.timestamp_utc),
2445                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2446                        "config": config_val,
2447                    })
2448                })
2449                .collect()
2450        };
2451        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2452    };
2453
2454    let template = ScanSetupTemplate {
2455        version: env!("CARGO_PKG_VERSION"),
2456        recent_scans_json,
2457        csp_nonce,
2458    };
2459    Html(
2460        template
2461            .render()
2462            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2463    )
2464}
2465
2466async fn healthz() -> &'static str {
2467    "ok"
2468}
2469
2470async fn api_version_handler() -> impl IntoResponse {
2471    axum::Json(serde_json::json!({
2472        "name": "oxide-sloc",
2473        "version": env!("CARGO_PKG_VERSION"),
2474    }))
2475}
2476
2477// ── Prometheus metrics ────────────────────────────────────────────────────────
2478
2479fn prom_runs_total() -> &'static prometheus::IntCounter {
2480    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2481    COUNTER.get_or_init(|| {
2482        prometheus::register_int_counter!(
2483            "oxide_sloc_runs_total",
2484            "Total number of completed analysis runs"
2485        )
2486        .expect("failed to register oxide_sloc_runs_total counter")
2487    })
2488}
2489
2490async fn metrics_handler() -> impl IntoResponse {
2491    use prometheus::Encoder as _;
2492    let mut buf = Vec::new();
2493    let encoder = prometheus::TextEncoder::new();
2494    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2495    (
2496        [(
2497            axum::http::header::CONTENT_TYPE,
2498            "text/plain; version=0.0.4; charset=utf-8",
2499        )],
2500        buf,
2501    )
2502}
2503
2504static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2505
2506async fn openapi_yaml_handler() -> impl IntoResponse {
2507    (
2508        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2509        OPENAPI_YAML,
2510    )
2511}
2512
2513static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2514static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2515
2516async fn llms_txt_handler() -> impl IntoResponse {
2517    (
2518        [
2519            (
2520                axum::http::header::CONTENT_TYPE,
2521                "text/plain; charset=utf-8",
2522            ),
2523            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2524        ],
2525        LLMS_TXT,
2526    )
2527}
2528
2529async fn llms_full_txt_handler() -> impl IntoResponse {
2530    (
2531        [
2532            (
2533                axum::http::header::CONTENT_TYPE,
2534                "text/plain; charset=utf-8",
2535            ),
2536            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2537        ],
2538        LLMS_FULL_TXT,
2539    )
2540}
2541
2542async fn api_docs_handler(
2543    State(state): State<AppState>,
2544    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2545) -> impl IntoResponse {
2546    let has_api_key = !state.api_keys.is_empty();
2547    Html(
2548        ApiDocsTemplate {
2549            has_api_key,
2550            csp_nonce,
2551            version: env!("CARGO_PKG_VERSION"),
2552        }
2553        .render()
2554        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2555    )
2556}
2557
2558async fn chart_js_handler() -> impl IntoResponse {
2559    (
2560        [
2561            (
2562                header::CONTENT_TYPE,
2563                "application/javascript; charset=utf-8",
2564            ),
2565            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2566        ],
2567        CHART_JS,
2568    )
2569}
2570
2571async fn report_chart_js_handler() -> impl IntoResponse {
2572    (
2573        [
2574            (
2575                header::CONTENT_TYPE,
2576                "application/javascript; charset=utf-8",
2577            ),
2578            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2579        ],
2580        REPORT_CHART_JS,
2581    )
2582}
2583
2584#[derive(Debug, Deserialize)]
2585struct AnalyzeForm {
2586    path: String,
2587    git_repo: Option<String>,
2588    git_ref: Option<String>,
2589    mixed_line_policy: Option<MixedLinePolicy>,
2590    python_docstrings_as_comments: Option<String>,
2591    generated_file_detection: Option<String>,
2592    minified_file_detection: Option<String>,
2593    vendor_directory_detection: Option<String>,
2594    include_lockfiles: Option<String>,
2595    binary_file_behavior: Option<BinaryFileBehavior>,
2596    output_dir: Option<String>,
2597    report_title: Option<String>,
2598    report_header_footer: Option<String>,
2599    include_globs: Option<String>,
2600    exclude_globs: Option<String>,
2601    submodule_breakdown: Option<String>,
2602    coverage_file: Option<String>,
2603    continuation_line_policy: Option<ContinuationLinePolicy>,
2604    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2605    count_compiler_directives: Option<String>,
2606    style_col_threshold: Option<String>,
2607    style_analysis_enabled: Option<String>,
2608    style_score_threshold: Option<String>,
2609    style_lang_scope: Option<String>,
2610    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2611    cocomo_mode: Option<String>,
2612    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2613    complexity_alert: Option<String>,
2614    /// Whether to exclude duplicate files from displayed SLOC totals.
2615    exclude_duplicates: Option<String>,
2616    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2617    activity_window: Option<String>,
2618}
2619
2620#[allow(clippy::struct_excessive_bools)]
2621#[derive(Debug, Serialize, Deserialize, Clone)]
2622struct ScanConfig {
2623    oxide_sloc_version: String,
2624    path: String,
2625    include_globs: String,
2626    exclude_globs: String,
2627    submodule_breakdown: bool,
2628    mixed_line_policy: String,
2629    python_docstrings_as_comments: bool,
2630    generated_file_detection: bool,
2631    minified_file_detection: bool,
2632    vendor_directory_detection: bool,
2633    include_lockfiles: bool,
2634    binary_file_behavior: String,
2635    output_dir: String,
2636    report_title: String,
2637    // IEEE 1045-1992 and advanced fields added in later release
2638    #[serde(default = "default_each_physical_line")]
2639    continuation_line_policy: String,
2640    #[serde(default = "default_count_as_comment")]
2641    blank_in_block_comment_policy: String,
2642    #[serde(default = "default_true_bool")]
2643    count_compiler_directives: bool,
2644    #[serde(default = "default_true_bool")]
2645    style_analysis_enabled: bool,
2646    #[serde(default = "default_style_col_threshold")]
2647    style_col_threshold: u16,
2648    #[serde(default)]
2649    style_score_threshold: u8,
2650    #[serde(default = "default_all_scope")]
2651    style_lang_scope: String,
2652    #[serde(default)]
2653    coverage_file: String,
2654    #[serde(default = "default_organic")]
2655    cocomo_mode: String,
2656    #[serde(default)]
2657    complexity_alert: u32,
2658    #[serde(default)]
2659    exclude_duplicates: bool,
2660    /// Git hotspots activity window in days (on by default; 0 = disabled).
2661    #[serde(default = "default_activity_window")]
2662    activity_window: u32,
2663}
2664
2665const fn default_activity_window() -> u32 {
2666    90
2667}
2668
2669fn default_each_physical_line() -> String {
2670    "each_physical_line".to_string()
2671}
2672fn default_count_as_comment() -> String {
2673    "count_as_comment".to_string()
2674}
2675const fn default_true_bool() -> bool {
2676    true
2677}
2678const fn default_style_col_threshold() -> u16 {
2679    80
2680}
2681fn default_all_scope() -> String {
2682    "all".to_string()
2683}
2684fn default_organic() -> String {
2685    "organic".to_string()
2686}
2687
2688#[derive(Debug, Deserialize, Default)]
2689struct IndexQuery {
2690    path: Option<String>,
2691    include_globs: Option<String>,
2692    exclude_globs: Option<String>,
2693    submodule_breakdown: Option<String>,
2694    mixed_line_policy: Option<String>,
2695    python_docstrings_as_comments: Option<String>,
2696    generated_file_detection: Option<String>,
2697    minified_file_detection: Option<String>,
2698    vendor_directory_detection: Option<String>,
2699    include_lockfiles: Option<String>,
2700    binary_file_behavior: Option<String>,
2701    output_dir: Option<String>,
2702    report_title: Option<String>,
2703    prefilled: Option<String>,
2704    git_repo: Option<String>,
2705    git_ref: Option<String>,
2706    // IEEE 1045-1992 and advanced fields
2707    continuation_line_policy: Option<String>,
2708    blank_in_block_comment_policy: Option<String>,
2709    count_compiler_directives: Option<String>,
2710    style_analysis_enabled: Option<String>,
2711    style_col_threshold: Option<String>,
2712    style_score_threshold: Option<String>,
2713    style_lang_scope: Option<String>,
2714    coverage_file: Option<String>,
2715    cocomo_mode: Option<String>,
2716    complexity_alert: Option<String>,
2717    exclude_duplicates: Option<String>,
2718    activity_window: Option<String>,
2719}
2720
2721#[derive(Debug, Deserialize)]
2722struct PreviewQuery {
2723    path: Option<String>,
2724    include_globs: Option<String>,
2725    exclude_globs: Option<String>,
2726}
2727
2728#[cfg(feature = "native-dialog")]
2729#[derive(Debug, Deserialize)]
2730struct PickDirectoryQuery {
2731    kind: Option<String>,
2732    current: Option<String>,
2733}
2734
2735#[cfg(not(feature = "native-dialog"))]
2736#[derive(Debug, Deserialize)]
2737struct PickDirectoryQuery {}
2738
2739#[derive(Debug, Deserialize, Default)]
2740struct ArtifactQuery {
2741    download: Option<String>,
2742}
2743
2744#[cfg(feature = "native-dialog")]
2745#[derive(Debug, Serialize)]
2746struct PickDirectoryResponse {
2747    selected_path: Option<String>,
2748    cancelled: bool,
2749}
2750
2751#[cfg(feature = "native-dialog")]
2752async fn pick_directory_handler(
2753    State(state): State<AppState>,
2754    Query(query): Query<PickDirectoryQuery>,
2755) -> Response {
2756    if state.server_mode {
2757        return StatusCode::NOT_FOUND.into_response();
2758    }
2759    // Return immediately without opening a dialog in headless / CI environments.
2760    if std::env::var("SLOC_HEADLESS").is_ok() {
2761        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2762            .into_response();
2763    }
2764
2765    let is_coverage = query.kind.as_deref() == Some("coverage");
2766    let title = match query.kind.as_deref() {
2767        Some("output") => "Select output directory",
2768        Some("reports") => "Select folder containing saved reports",
2769        Some("coverage") => "Select LCOV coverage file",
2770        _ => "Select project directory",
2771    }
2772    .to_owned();
2773    let current = query.current.clone();
2774
2775    let picked = tokio::task::spawn_blocking(move || {
2776        // Windows: attach to the foreground thread so the dialog inherits focus,
2777        // and kick off a watcher that flashes the dialog once it appears.
2778        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2779        let fg_tid = win_dialog_focus::attach_to_foreground();
2780        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2781        win_dialog_focus::flash_dialog_when_ready(title.clone());
2782
2783        let mut dialog = rfd::FileDialog::new().set_title(&title);
2784        if let Some(current) = current.as_deref() {
2785            let resolved = resolve_input_path(current);
2786            let seed = if resolved.is_dir() {
2787                Some(resolved)
2788            } else {
2789                resolved.parent().map(Path::to_path_buf)
2790            };
2791            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2792                dialog = dialog.set_directory(seed_dir);
2793            }
2794        }
2795        let result = if is_coverage {
2796            dialog
2797                .add_filter(
2798                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2799                    &["info", "lcov", "xml", "json"],
2800                )
2801                .pick_file()
2802        } else {
2803            dialog.pick_folder()
2804        };
2805
2806        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2807        win_dialog_focus::detach_from_foreground(fg_tid);
2808
2809        result
2810    })
2811    .await
2812    .unwrap_or(None);
2813
2814    Json(PickDirectoryResponse {
2815        selected_path: picked.as_ref().map(|p| display_path(p)),
2816        cancelled: picked.is_none(),
2817    })
2818    .into_response()
2819}
2820
2821#[cfg(not(feature = "native-dialog"))]
2822async fn pick_directory_handler(
2823    State(_state): State<AppState>,
2824    Query(_query): Query<PickDirectoryQuery>,
2825) -> Response {
2826    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2827}
2828
2829#[cfg(feature = "native-dialog")]
2830async fn pick_file_handler(State(state): State<AppState>) -> Response {
2831    if state.server_mode {
2832        return StatusCode::NOT_FOUND.into_response();
2833    }
2834    if std::env::var("SLOC_HEADLESS").is_ok() {
2835        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2836            .into_response();
2837    }
2838    let picked = tokio::task::spawn_blocking(|| {
2839        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2840        let fg_tid = win_dialog_focus::attach_to_foreground();
2841        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2842        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2843
2844        let result = rfd::FileDialog::new()
2845            .set_title("Select HTML report")
2846            .add_filter("HTML report", &["html"])
2847            .pick_file();
2848
2849        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2850        win_dialog_focus::detach_from_foreground(fg_tid);
2851
2852        result
2853    })
2854    .await
2855    .unwrap_or(None);
2856    Json(PickDirectoryResponse {
2857        selected_path: picked.as_ref().map(|p| display_path(p)),
2858        cancelled: picked.is_none(),
2859    })
2860    .into_response()
2861}
2862
2863#[cfg(not(feature = "native-dialog"))]
2864async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2865    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2866}
2867
2868// ── Browser-upload handlers (server mode only) ────────────────────────────────
2869
2870/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2871/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2872fn is_upload_tmp_path(path: &Path) -> bool {
2873    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2874    path.starts_with(&upload_root)
2875}
2876
2877/// Returns true when `path` is the built-in sample or test-fixture directory.
2878/// These paths ship with the server binary and are always safe to scan/preview.
2879fn is_sample_path(path: &Path) -> bool {
2880    let root = workspace_root();
2881    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2882}
2883
2884/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2885fn upload_base_dir() -> PathBuf {
2886    std::env::temp_dir().join("oxide-sloc-uploads")
2887}
2888
2889/// Returns the staging path for a given upload id inside the base dir.
2890fn upload_staging_path(id: &str) -> PathBuf {
2891    upload_base_dir().join(id)
2892}
2893
2894/// Validate basic field constraints on a directory-upload request.
2895/// Returns an error `Response` if the request should be rejected immediately.
2896#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2897fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2898    const MAX_FILES: usize = 50_000;
2899    if body.files.is_empty() {
2900        return Err((
2901            StatusCode::BAD_REQUEST,
2902            Json(serde_json::json!({"error": "No files received"})),
2903        )
2904            .into_response());
2905    }
2906    if body.files.len() > MAX_FILES {
2907        return Err((
2908            StatusCode::PAYLOAD_TOO_LARGE,
2909            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2910        )
2911            .into_response());
2912    }
2913    Ok(())
2914}
2915
2916/// Resolve or create the staging directory for a directory upload.
2917/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2918fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2919    match id {
2920        Some(id)
2921            if !id.is_empty()
2922                && id.len() <= 36
2923                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2924        {
2925            (id.to_string(), upload_staging_path(id))
2926        }
2927        _ => {
2928            let new_id = uuid::Uuid::new_v4().to_string();
2929            let staging = upload_staging_path(&new_id);
2930            (new_id, staging)
2931        }
2932    }
2933}
2934
2935/// Decode, size-check, and write one uploaded file entry into `staging`.
2936/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2937/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2938/// cleaning up `staging` before propagating the error.
2939#[allow(clippy::result_large_err)]
2940async fn stage_decoded_entry(
2941    entry: &UploadedFile,
2942    staging: &Path,
2943    total_bytes: &mut usize,
2944    project_root: &mut Option<PathBuf>,
2945) -> Result<(), Response> {
2946    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2947
2948    let Ok(data) = base64::Engine::decode(
2949        &base64::engine::general_purpose::STANDARD,
2950        entry.content.as_bytes(),
2951    ) else {
2952        return Ok(());
2953    };
2954
2955    *total_bytes += data.len();
2956    if *total_bytes > MAX_TOTAL_BYTES {
2957        return Err((
2958            StatusCode::PAYLOAD_TOO_LARGE,
2959            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2960        )
2961            .into_response());
2962    }
2963
2964    let rel = std::path::Path::new(&entry.path);
2965    if project_root.is_none()
2966        && let Some(first) = rel.components().next()
2967    {
2968        *project_root = Some(staging.join(first.as_os_str()));
2969    }
2970
2971    let dest = staging.join(rel);
2972    if let Some(parent) = dest.parent()
2973        && tokio::fs::create_dir_all(parent).await.is_err()
2974    {
2975        return Err((
2976            StatusCode::INTERNAL_SERVER_ERROR,
2977            Json(serde_json::json!({"error": "Failed to create directory structure"})),
2978        )
2979            .into_response());
2980    }
2981
2982    if tokio::fs::write(&dest, &data).await.is_err() {
2983        return Err((
2984            StatusCode::INTERNAL_SERVER_ERROR,
2985            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2986        )
2987            .into_response());
2988    }
2989
2990    Ok(())
2991}
2992
2993/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2994/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2995/// an error `Response` on failure (staging dir is cleaned up before returning).
2996async fn write_upload_files(
2997    files: &[UploadedFile],
2998    staging: &Path,
2999    upload_id: &str,
3000) -> Result<(usize, Option<PathBuf>), Response> {
3001    let mut total_bytes: usize = 0;
3002    let mut project_root: Option<PathBuf> = None;
3003
3004    for entry in files {
3005        let rel = std::path::Path::new(&entry.path);
3006        if rel
3007            .components()
3008            .any(|c| matches!(c, std::path::Component::ParentDir))
3009        {
3010            // Reject the entire upload on the first path traversal attempt.
3011            let _ = tokio::fs::remove_dir_all(staging).await;
3012            tracing::warn!(
3013                event = "upload_path_traversal",
3014                upload_id = %upload_id,
3015                path = %entry.path,
3016                "Upload rejected: path traversal component detected"
3017            );
3018            return Err((
3019                StatusCode::BAD_REQUEST,
3020                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3021            )
3022                .into_response());
3023        }
3024
3025        if let Err(resp) =
3026            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3027        {
3028            let _ = tokio::fs::remove_dir_all(staging).await;
3029            return Err(resp);
3030        }
3031    }
3032
3033    Ok((files.len(), project_root))
3034}
3035
3036/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
3037/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
3038fn parse_tarball_size_caps() -> (u64, u64) {
3039    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3040        .ok()
3041        .and_then(|v| v.parse().ok())
3042        .unwrap_or(2048_u64)
3043        * 1024
3044        * 1024;
3045    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3046        .ok()
3047        .and_then(|v| v.parse().ok())
3048        .unwrap_or(10_240_u64)
3049        * 1024
3050        * 1024;
3051    (compressed, decompressed)
3052}
3053
3054/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
3055/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
3056/// are rejected before the streaming handler is invoked.
3057fn tarball_http_body_limit_bytes() -> usize {
3058    std::env::var("SLOC_MAX_TARBALL_MB")
3059        .ok()
3060        .and_then(|v| v.parse::<usize>().ok())
3061        .unwrap_or(2048)
3062        .saturating_mul(1024 * 1024)
3063}
3064
3065/// Stream `body` into `dest_path`, enforcing `max_bytes`.
3066/// Returns the number of compressed bytes written, or an error `Response`.
3067/// Cleans up `dest_path` on error.
3068#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3069async fn stream_body_to_file(
3070    body: axum::body::Body,
3071    dest_path: &Path,
3072    max_bytes: u64,
3073) -> Result<u64, Response> {
3074    use http_body_util::BodyExt as _;
3075    use tokio::io::AsyncWriteExt as _;
3076
3077    let mut file = match tokio::fs::File::create(dest_path).await {
3078        Ok(f) => f,
3079        Err(e) => {
3080            tracing::error!(
3081                event = "upload_io_error",
3082                "failed to create tarball temp file: {e}"
3083            );
3084            return Err((
3085                StatusCode::INTERNAL_SERVER_ERROR,
3086                Json(serde_json::json!({"error": "Upload initialization failed"})),
3087            )
3088                .into_response());
3089        }
3090    };
3091
3092    let mut body = body;
3093    let mut written: u64 = 0;
3094    loop {
3095        match body.frame().await {
3096            None => break,
3097            Some(Err(e)) => {
3098                let _ = tokio::fs::remove_file(dest_path).await;
3099                return Err((
3100                    StatusCode::BAD_REQUEST,
3101                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3102                )
3103                    .into_response());
3104            }
3105            Some(Ok(frame)) => {
3106                if let Ok(data) = frame.into_data() {
3107                    written += data.len() as u64;
3108                    if written > max_bytes {
3109                        let _ = tokio::fs::remove_file(dest_path).await;
3110                        return Err((
3111                            StatusCode::PAYLOAD_TOO_LARGE,
3112                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3113                        )
3114                            .into_response());
3115                    }
3116                    if let Err(e) = file.write_all(&data).await {
3117                        let _ = tokio::fs::remove_file(dest_path).await;
3118                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3119                        return Err((
3120                            StatusCode::INTERNAL_SERVER_ERROR,
3121                            Json(serde_json::json!({"error": "Upload write failed"})),
3122                        )
3123                            .into_response());
3124                    }
3125                }
3126            }
3127        }
3128    }
3129    drop(file);
3130    Ok(written)
3131}
3132
3133/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
3134/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
3135/// on failure (staging dir is cleaned up before returning).
3136#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3137async fn extract_tarball_to_staging(
3138    tarball_path: &Path,
3139    staging: &Path,
3140    max_decompressed_bytes: u64,
3141) -> Result<(), Response> {
3142    let staging_clone = staging.to_path_buf();
3143    let tarball_clone = tarball_path.to_path_buf();
3144    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3145        let file = std::fs::File::open(&tarball_clone)?;
3146        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3147        let limited = SizeLimitReader {
3148            inner: gz,
3149            remaining: max_decompressed_bytes,
3150        };
3151        let mut archive = tar::Archive::new(limited);
3152        archive.set_overwrite(true);
3153        archive.set_preserve_permissions(false);
3154        std::fs::create_dir_all(&staging_clone)?;
3155        archive.unpack(&staging_clone)?;
3156        Ok(())
3157    })
3158    .await;
3159    let _ = tokio::fs::remove_file(tarball_path).await;
3160
3161    match extract_result {
3162        Ok(Ok(())) => Ok(()),
3163        Ok(Err(e)) => {
3164            let _ = tokio::fs::remove_dir_all(staging).await;
3165            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3166            tracing::warn!(
3167                event = "upload_extract_error",
3168                "tarball extraction failed: {e:#}"
3169            );
3170            let (status, msg) = if is_size_limit {
3171                (
3172                    StatusCode::PAYLOAD_TOO_LARGE,
3173                    "Archive exceeds the decompressed size limit",
3174                )
3175            } else {
3176                (StatusCode::BAD_REQUEST, "Failed to extract archive")
3177            };
3178            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3179        }
3180        Err(e) => {
3181            let _ = tokio::fs::remove_dir_all(staging).await;
3182            tracing::error!(
3183                event = "upload_extract_panic",
3184                "tarball extraction task panicked: {e}"
3185            );
3186            Err((
3187                StatusCode::INTERNAL_SERVER_ERROR,
3188                Json(serde_json::json!({"error": "Archive extraction failed"})),
3189            )
3190                .into_response())
3191        }
3192    }
3193}
3194
3195/// If `staging` contains exactly one top-level directory, return its path
3196/// (the common case when the archive was created with `webkitRelativePath`).
3197/// Otherwise return `None`.
3198async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3199    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3200    let first = entries.next_entry().await.ok()??;
3201    if !first.path().is_dir() {
3202        return None;
3203    }
3204    if entries.next_entry().await.unwrap_or(None).is_some() {
3205        return None;
3206    }
3207    Some(first.path())
3208}
3209
3210/// Request body for `POST /api/upload-directory`.
3211///
3212/// Each entry carries a relative path (identical to the browser's
3213/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
3214/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
3215/// avoids pulling in a `multipart` library that is not in the vendor archive.
3216#[derive(Deserialize)]
3217struct UploadDirRequest {
3218    files: Vec<UploadedFile>,
3219    /// If provided, append this batch to an existing upload session instead of
3220    /// creating a new staging directory. Must be a plain UUID (no path separators).
3221    upload_id: Option<String>,
3222}
3223
3224#[derive(Deserialize)]
3225struct UploadedFile {
3226    /// `webkitRelativePath` value from the browser File object.
3227    path: String,
3228    /// Raw file bytes encoded as standard base64.
3229    content: String,
3230}
3231
3232/// POST /api/upload-directory
3233///
3234/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
3235/// Saves all files to a temp staging directory preserving their relative paths,
3236/// then returns the server-side root directory path so the caller can populate
3237/// the scan-path field and run a normal analysis.
3238///
3239/// Only available in server mode; returns 404 in local mode (use the native
3240/// rfd dialog instead).
3241async fn upload_directory_handler(
3242    State(state): State<AppState>,
3243    Json(body): Json<UploadDirRequest>,
3244) -> Response {
3245    if !state.server_mode {
3246        return StatusCode::NOT_FOUND.into_response();
3247    }
3248    if let Err(resp) = validate_upload_dir_request(&body) {
3249        return resp;
3250    }
3251    // Reuse an existing staging dir when the client sends a continuation batch,
3252    // otherwise create a fresh one. Validate the id to prevent path traversal.
3253    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3254    match write_upload_files(&body.files, &staging, &upload_id).await {
3255        Ok((file_count, project_root)) => {
3256            let scan_root = project_root.unwrap_or_else(|| staging.clone());
3257            Json(serde_json::json!({
3258                "tmp_path": scan_root.to_string_lossy(),
3259                "file_count": file_count,
3260                "upload_id": upload_id.clone()
3261            }))
3262            .into_response()
3263        }
3264        Err(resp) => resp,
3265    }
3266}
3267
3268/// Request body for `POST /api/upload-file`.
3269#[derive(Deserialize)]
3270struct UploadFileRequest {
3271    /// Original filename (used only to preserve the extension).
3272    filename: String,
3273    /// File bytes encoded as standard base64.
3274    content: String,
3275}
3276
3277/// POST /api/upload-file
3278///
3279/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
3280/// Accepts `{ "filename": "…", "content": "<base64>" }`.
3281/// Only available in server mode.
3282async fn upload_file_handler(
3283    State(state): State<AppState>,
3284    Json(body): Json<UploadFileRequest>,
3285) -> Response {
3286    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
3287
3288    if !state.server_mode {
3289        return StatusCode::NOT_FOUND.into_response();
3290    }
3291
3292    let Ok(data) = base64::Engine::decode(
3293        &base64::engine::general_purpose::STANDARD,
3294        body.content.as_bytes(),
3295    ) else {
3296        return (
3297            StatusCode::BAD_REQUEST,
3298            Json(serde_json::json!({"error": "Invalid base64 content"})),
3299        )
3300            .into_response();
3301    };
3302
3303    if data.len() > MAX_FILE_BYTES {
3304        return (
3305            StatusCode::PAYLOAD_TOO_LARGE,
3306            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3307        )
3308            .into_response();
3309    }
3310
3311    // Sanitise: strip any directory component from the filename.
3312    let filename = std::path::Path::new(&body.filename)
3313        .file_name()
3314        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3315
3316    let upload_id = uuid::Uuid::new_v4();
3317    let staging = std::env::temp_dir()
3318        .join("oxide-sloc-uploads")
3319        .join(upload_id.to_string());
3320
3321    if tokio::fs::create_dir_all(&staging).await.is_err() {
3322        return (
3323            StatusCode::INTERNAL_SERVER_ERROR,
3324            Json(serde_json::json!({"error": "Failed to create staging directory"})),
3325        )
3326            .into_response();
3327    }
3328
3329    let dest = staging.join(&filename);
3330    if tokio::fs::write(&dest, &data).await.is_err() {
3331        let _ = tokio::fs::remove_dir_all(&staging).await;
3332        return (
3333            StatusCode::INTERNAL_SERVER_ERROR,
3334            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3335        )
3336            .into_response();
3337    }
3338
3339    Json(serde_json::json!({
3340        "tmp_path": dest.to_string_lossy(),
3341        "upload_id": upload_id.to_string()
3342    }))
3343    .into_response()
3344}
3345
3346/// POST /api/upload-tarball
3347///
3348/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
3349/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
3350/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
3351/// project root. The two size fields power the "Original / Compressed project size" display in the
3352/// web UI.
3353///
3354/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
3355/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
3356/// cap during decompression. The browser-side JS creates the archive one file at a time using
3357/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
3358/// project size.
3359/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
3360/// decompressed. Wraps any `std::io::Read` source.
3361struct SizeLimitReader<R> {
3362    inner: R,
3363    remaining: u64,
3364}
3365impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3366    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3367        if self.remaining == 0 {
3368            return Err(std::io::Error::other("decompressed size limit exceeded"));
3369        }
3370        let n = self.inner.read(buf)?;
3371        self.remaining = self.remaining.saturating_sub(n as u64);
3372        Ok(n)
3373    }
3374}
3375
3376async fn upload_tarball_handler(
3377    State(state): State<AppState>,
3378    request: axum::extract::Request,
3379) -> Response {
3380    if !state.server_mode {
3381        return StatusCode::NOT_FOUND.into_response();
3382    }
3383
3384    let upload_id = uuid::Uuid::new_v4().to_string();
3385    let upload_base = upload_base_dir();
3386    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3387    let staging = upload_staging_path(&upload_id);
3388    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3389
3390    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3391        tracing::error!(
3392            event = "upload_io_error",
3393            "failed to create upload base dir: {e}"
3394        );
3395        return (
3396            StatusCode::INTERNAL_SERVER_ERROR,
3397            Json(serde_json::json!({"error": "Upload initialization failed"})),
3398        )
3399            .into_response();
3400    }
3401
3402    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
3403    let compressed_bytes =
3404        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3405            Ok(n) => n,
3406            Err(resp) => return resp,
3407        };
3408
3409    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
3410    if let Err(resp) =
3411        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3412    {
3413        return resp;
3414    }
3415
3416    // ── 3. Find the project root inside the staging dir ───────────────────────
3417    // If the tar contained a single top-level directory (the common case when the
3418    // browser uses `webkitRelativePath`), return that as the scan root so the path
3419    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3420    let scan_root = find_single_top_dir(&staging)
3421        .await
3422        .unwrap_or_else(|| staging.clone());
3423
3424    // Compute original (uncompressed) size of the extracted tree.
3425    let original_bytes = tokio::task::spawn_blocking({
3426        let p = scan_root.clone();
3427        move || dir_size_bytes(&p)
3428    })
3429    .await
3430    .unwrap_or(0);
3431
3432    Json(serde_json::json!({
3433        "tmp_path": scan_root.to_string_lossy(),
3434        "upload_id": upload_id,
3435        "compressed_bytes": compressed_bytes,
3436        "original_bytes": original_bytes,
3437    }))
3438    .into_response()
3439}
3440
3441#[derive(Deserialize)]
3442struct LocateReportForm {
3443    file_path: String,
3444    #[serde(default)]
3445    redirect_url: Option<String>,
3446    #[serde(default)]
3447    expected_run_id: Option<String>,
3448}
3449
3450/// Render a view-reports error page and return it as a `Response`.
3451fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3452    let html = ErrorTemplate {
3453        message: message.into(),
3454        last_report_url: Some("/view-reports".to_string()),
3455        last_report_label: Some("View Reports".to_string()),
3456        run_id: None,
3457        error_code: None,
3458        csp_nonce: csp_nonce.to_owned(),
3459        version: env!("CARGO_PKG_VERSION"),
3460    }
3461    .render()
3462    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3463    Html(html).into_response()
3464}
3465
3466/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3467fn registry_entry_from_run(
3468    run: &AnalysisRun,
3469    json_path: PathBuf,
3470    html_path: PathBuf,
3471) -> RegistryEntry {
3472    let project_label = run.input_roots.first().map_or_else(
3473        || "Unknown Project".to_string(),
3474        |r| sanitize_project_label(r),
3475    );
3476    RegistryEntry {
3477        run_id: run.tool.run_id.clone(),
3478        timestamp_utc: run.tool.timestamp_utc,
3479        project_label,
3480        input_roots: run.input_roots.clone(),
3481        json_path: Some(json_path),
3482        html_path: Some(html_path),
3483        pdf_path: None,
3484        summary: ScanSummarySnapshot::from(&run.summary_totals),
3485        csv_path: None,
3486        xlsx_path: None,
3487        git_branch: None,
3488        git_commit: None,
3489        git_commit_long: None,
3490        git_author: None,
3491        git_tags: None,
3492        git_nearest_tag: None,
3493        git_commit_date: None,
3494    }
3495}
3496
3497/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3498/// immediately without requiring a server restart.
3499pub(crate) async fn register_artifacts_in_registry(
3500    state: &AppState,
3501    label: &str,
3502    run: &AnalysisRun,
3503    artifacts: &RunArtifacts,
3504) {
3505    let Some(json_path) = artifacts.json_path.clone() else {
3506        return;
3507    };
3508    let Some(html_path) = artifacts.html_path.clone() else {
3509        return;
3510    };
3511    let mut entry = registry_entry_from_run(run, json_path, html_path);
3512    entry.project_label = label.to_owned();
3513    let mut reg = state.registry.lock().await;
3514    reg.add_entry(entry);
3515    let _ = reg.save(&state.registry_path);
3516}
3517
3518fn is_html_report_file(p: &Path) -> bool {
3519    p.is_file()
3520        && p.extension()
3521            .and_then(|x| x.to_str())
3522            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3523        && p.file_name()
3524            .and_then(|n| n.to_str())
3525            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3526}
3527
3528fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3529    fs::read_dir(dir)
3530        .ok()?
3531        .flatten()
3532        .map(|e| e.path())
3533        .find(|p| is_html_report_file(p))
3534}
3535
3536fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3537    if let Some(f) = find_html_report_in_dir(dir) {
3538        return Some(f);
3539    }
3540    if let Ok(rd) = fs::read_dir(dir) {
3541        for entry in rd.flatten() {
3542            let sub = entry.path();
3543            if sub.is_dir()
3544                && let Some(f) = find_html_report_in_dir(&sub)
3545            {
3546                return Some(f);
3547            }
3548        }
3549    }
3550    None
3551}
3552
3553/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3554/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3555///
3556/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3557#[allow(clippy::result_large_err)]
3558fn validate_locate_request(
3559    state: &AppState,
3560    file_path: &str,
3561    csp_nonce: &str,
3562) -> Result<(PathBuf, PathBuf), Response> {
3563    let raw = PathBuf::from(file_path);
3564
3565    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3566    let html_path = if raw.is_dir() {
3567        let found = find_html_report_in_tree(&raw);
3568        match found {
3569            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3570            None => {
3571                return Err(locate_report_error(
3572                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3573                     the folder that contains your scan output (result_*.html or report_*.html).",
3574                    csp_nonce,
3575                ));
3576            }
3577        }
3578    } else {
3579        let file_ext = raw
3580            .extension()
3581            .and_then(|e| e.to_str())
3582            .unwrap_or("")
3583            .to_ascii_lowercase();
3584        if file_ext != "html" {
3585            return Err(locate_report_error(
3586                "Please select the scan output folder, or an .html report file directly.",
3587                csp_nonce,
3588            ));
3589        }
3590        match fs::canonicalize(&raw) {
3591            Ok(p) => strip_unc_prefix(p),
3592            Err(_) => {
3593                return Err(locate_report_error(
3594                    "Report file not found or path is invalid.",
3595                    csp_nonce,
3596                ));
3597            }
3598        }
3599    };
3600
3601    if state.server_mode {
3602        let output_root = resolve_output_root(None);
3603        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3604        if !html_path.starts_with(&canonical_root) {
3605            return Err(locate_report_error(
3606                "Report file must be within the configured output directory.",
3607                csp_nonce,
3608            ));
3609        }
3610    }
3611    let parent = match html_path.parent() {
3612        Some(p) => p.to_path_buf(),
3613        None => {
3614            return Err(locate_report_error(
3615                "Report file has no parent directory.",
3616                csp_nonce,
3617            ));
3618        }
3619    };
3620    Ok((html_path, parent))
3621}
3622
3623/// JSON-or-HTML error for `locate_report_handler` error paths.
3624fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3625    if want_json {
3626        (
3627            StatusCode::UNPROCESSABLE_ENTITY,
3628            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3629        )
3630            .into_response()
3631    } else {
3632        locate_report_error(msg, csp_nonce)
3633    }
3634}
3635
3636/// JSON-or-redirect success for locate/relocate handler success paths.
3637fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3638    if want_json {
3639        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3640    } else {
3641        axum::response::Redirect::to(redirect).into_response()
3642    }
3643}
3644
3645/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3646/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3647fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3648    for jpath in candidates {
3649        if let Ok(run) = read_json(jpath)
3650            && (expected.is_empty() || run.tool.run_id == expected)
3651        {
3652            return Some((jpath.clone(), run.tool.run_id));
3653        }
3654    }
3655    None
3656}
3657
3658fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3659    html_path
3660        .parent()
3661        .and_then(|p| p.parent())
3662        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3663}
3664
3665fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3666    let mut hits = collect_result_json_candidates(scan_root);
3667    if hits.is_empty() {
3668        hits = collect_result_json_candidates(parent);
3669    }
3670    hits.sort();
3671    hits
3672}
3673
3674#[allow(clippy::too_many_lines)]
3675async fn locate_report_handler(
3676    State(state): State<AppState>,
3677    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3678    headers: axum::http::HeaderMap,
3679    Form(form): Form<LocateReportForm>,
3680) -> impl IntoResponse {
3681    let want_json = headers
3682        .get(axum::http::header::ACCEPT)
3683        .and_then(|v| v.to_str().ok())
3684        .is_some_and(|v| v.contains("application/json"));
3685
3686    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3687        Ok(v) => v,
3688        Err(resp) => {
3689            if want_json {
3690                return locate_handler_err(
3691                    true,
3692                    "No HTML report file found in the selected folder. \
3693                     Make sure you selected the folder that contains your \
3694                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3695                        .to_string(),
3696                    &csp_nonce,
3697                );
3698            }
3699            return resp;
3700        }
3701    };
3702
3703    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3704    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3705    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3706    let scan_root: &Path = &scan_root_owned;
3707    let json_candidates = gather_json_candidates(scan_root, &parent);
3708
3709    // If the expected_run_id was provided, find a JSON that matches it exactly.
3710    let expected_run_id = form
3711        .expected_run_id
3712        .as_deref()
3713        .unwrap_or("")
3714        .trim()
3715        .to_string();
3716
3717    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3718
3719    // If we have candidates but none matched the expected run_id, surface a clear error.
3720    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3721        let actual = json_candidates
3722            .iter()
3723            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3724            .unwrap_or_else(|| "unknown".to_string());
3725        return locate_handler_err(
3726            want_json,
3727            format!(
3728                "This folder contains a different scan.\n\n\
3729                 Expected run ID : {expected_run_id}\n\
3730                 Found run ID    : {actual}\n\n\
3731                 Please select the folder that contains the correct scan output."
3732            ),
3733            &csp_nonce,
3734        );
3735    }
3736
3737    let safe_redirect = form
3738        .redirect_url
3739        .as_deref()
3740        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3741        .unwrap_or("/view-reports?linked=1")
3742        .to_string();
3743
3744    let mut reg = state.registry.lock().await;
3745
3746    if let Some((json_path, run_id)) = matched_json {
3747        // Match by run_id in the registry (works even after files are moved).
3748        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3749            entry.html_path = Some(html_path);
3750            entry.json_path = Some(json_path);
3751            let _ = reg.save(&state.registry_path);
3752            drop(reg);
3753            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3754            state.artifacts.lock().await.remove(&run_id);
3755            return redirect_or_json_ok(want_json, &safe_redirect);
3756        }
3757        // No existing entry — build one from the JSON.
3758        match read_json(&json_path) {
3759            Ok(run) => {
3760                let entry = registry_entry_from_run(&run, json_path, html_path);
3761                reg.add_entry(entry);
3762                let _ = reg.save(&state.registry_path);
3763                drop(reg);
3764                state.artifacts.lock().await.remove(&run_id);
3765                return redirect_or_json_ok(want_json, &safe_redirect);
3766            }
3767            Err(e) => {
3768                drop(reg);
3769                return locate_handler_err(
3770                    want_json,
3771                    format!(
3772                        "Found the scan folder but could not parse the result JSON.\n\n\
3773                         The file may have been saved by an older version of OxideSLOC. \
3774                         Re-running the analysis will create a fresh, compatible record.\n\n\
3775                         Error: {e}"
3776                    ),
3777                    &csp_nonce,
3778                );
3779            }
3780        }
3781    }
3782
3783    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3784    if let Some(entry) = reg
3785        .entries
3786        .iter_mut()
3787        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3788    {
3789        entry.html_path = Some(html_path.clone());
3790        let _ = reg.save(&state.registry_path);
3791        drop(reg);
3792        state.artifacts.lock().await.remove(&expected_run_id);
3793        return redirect_or_json_ok(want_json, &safe_redirect);
3794    }
3795
3796    drop(reg);
3797    let hint = if state.server_mode {
3798        String::new()
3799    } else {
3800        format!(
3801            "\n\nSearched folder : {}\nHTML found      : {}",
3802            scan_root.display(),
3803            html_path.display()
3804        )
3805    };
3806    locate_handler_err(
3807        want_json,
3808        format!(
3809            "Could not link this report.\n\n\
3810             No result_*.json was found in the selected folder. \
3811             Make sure you selected the top-level scan output folder \
3812             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3813        ),
3814        &csp_nonce,
3815    )
3816}
3817
3818/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3819fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3820    fs::read_dir(dir)
3821        .ok()?
3822        .flatten()
3823        .map(|e| e.path())
3824        .find(|p| {
3825            p.is_file()
3826                && p.file_stem()
3827                    .and_then(|n| n.to_str())
3828                    .is_some_and(|n| n.starts_with("result"))
3829                && p.extension()
3830                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3831        })
3832}
3833
3834#[derive(Deserialize)]
3835struct LocateReportsDirForm {
3836    folder_path: String,
3837}
3838
3839#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3840async fn locate_reports_dir_handler(
3841    State(state): State<AppState>,
3842    Form(form): Form<LocateReportsDirForm>,
3843) -> impl IntoResponse {
3844    if state.server_mode {
3845        return StatusCode::NOT_FOUND.into_response();
3846    }
3847    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3848        Ok(p) => strip_unc_prefix(p),
3849        Err(_) => {
3850            return axum::response::Redirect::to(
3851                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3852            )
3853            .into_response();
3854        }
3855    };
3856    if !folder.is_dir() {
3857        return axum::response::Redirect::to(
3858            "/view-reports?error=Selected+path+is+not+a+directory.",
3859        )
3860        .into_response();
3861    }
3862
3863    let candidates = collect_result_json_candidates(&folder);
3864
3865    if candidates.is_empty() {
3866        return axum::response::Redirect::to(
3867            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3868        )
3869        .into_response();
3870    }
3871
3872    let mut linked_count: usize = 0;
3873    let mut reg = state.registry.lock().await;
3874    for json_path in candidates {
3875        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3876            continue;
3877        };
3878        if is_dir_already_registered(&reg, &parent) {
3879            continue;
3880        }
3881        let Some(entry) = build_registry_entry_from_json(json_path) else {
3882            continue;
3883        };
3884        reg.add_entry(entry);
3885        linked_count += 1;
3886    }
3887    let _ = reg.save(&state.registry_path);
3888    drop(reg);
3889
3890    if linked_count == 0 {
3891        return axum::response::Redirect::to(
3892            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3893        )
3894        .into_response();
3895    }
3896    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3897}
3898
3899#[derive(Deserialize)]
3900struct RelocateScanForm {
3901    run_id: String,
3902    folder_path: String,
3903    redirect_url: String,
3904}
3905
3906/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3907/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3908fn relocate_folder_err(
3909    want_json: bool,
3910    status: StatusCode,
3911    msg: &str,
3912    run_id: &str,
3913    folder_hint: &str,
3914    redirect_url: &str,
3915    csp_nonce: &str,
3916) -> Response {
3917    if want_json {
3918        (
3919            status,
3920            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3921        )
3922            .into_response()
3923    } else {
3924        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3925    }
3926}
3927
3928#[allow(clippy::too_many_lines)]
3929async fn relocate_scan_handler(
3930    State(state): State<AppState>,
3931    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3932    headers: axum::http::HeaderMap,
3933    Form(form): Form<RelocateScanForm>,
3934) -> impl IntoResponse {
3935    let want_json = headers
3936        .get(axum::http::header::ACCEPT)
3937        .and_then(|v| v.to_str().ok())
3938        .is_some_and(|v| v.contains("application/json"));
3939    if state.server_mode {
3940        return StatusCode::NOT_FOUND.into_response();
3941    }
3942
3943    let run_id = form.run_id.trim().to_string();
3944    let redirect_url = form.redirect_url.trim().to_string();
3945
3946    let run_exists = {
3947        let reg = state.registry.lock().await;
3948        reg.find_by_run_id(&run_id).is_some()
3949    };
3950    if !run_exists {
3951        if want_json {
3952            return (
3953                StatusCode::NOT_FOUND,
3954                axum::Json(serde_json::json!({
3955                    "ok": false,
3956                    "message": format!("Run ID '{run_id}' not found in registry.")
3957                })),
3958            )
3959                .into_response();
3960        }
3961        let html = ErrorTemplate {
3962            message: format!("Run ID '{run_id}' not found in registry."),
3963            last_report_url: Some("/compare-scans".to_string()),
3964            last_report_label: Some("Compare Scans".to_string()),
3965            run_id: Some(run_id.clone()),
3966            error_code: Some(404),
3967            csp_nonce: csp_nonce.clone(),
3968            version: env!("CARGO_PKG_VERSION"),
3969        }
3970        .render()
3971        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3972        return Html(html).into_response();
3973    }
3974
3975    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3976        Ok(p) => strip_unc_prefix(p),
3977        Err(_) => {
3978            return relocate_folder_err(
3979                want_json,
3980                StatusCode::UNPROCESSABLE_ENTITY,
3981                "Folder not found or path is invalid.",
3982                &run_id,
3983                form.folder_path.trim(),
3984                &redirect_url,
3985                &csp_nonce,
3986            );
3987        }
3988    };
3989    if !folder.is_dir() {
3990        return relocate_folder_err(
3991            want_json,
3992            StatusCode::UNPROCESSABLE_ENTITY,
3993            "Selected path is not a directory.",
3994            &run_id,
3995            &folder.display().to_string(),
3996            &redirect_url,
3997            &csp_nonce,
3998        );
3999    }
4000
4001    let json_candidates = find_result_files_by_ext(&folder, "json");
4002    if json_candidates.is_empty() {
4003        let msg = format!(
4004            "No result JSON files found in the selected folder.\nSearched: {}",
4005            folder.display()
4006        );
4007        return relocate_folder_err(
4008            want_json,
4009            StatusCode::UNPROCESSABLE_ENTITY,
4010            &msg,
4011            &run_id,
4012            &folder.display().to_string(),
4013            &redirect_url,
4014            &csp_nonce,
4015        );
4016    }
4017
4018    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4019        let msg = format!(
4020            "No matching scan found in the selected folder.\n\
4021             The JSON files present do not contain run ID: {run_id}\n\
4022             Searched: {}",
4023            folder.display()
4024        );
4025        return relocate_folder_err(
4026            want_json,
4027            StatusCode::UNPROCESSABLE_ENTITY,
4028            &msg,
4029            &run_id,
4030            &folder.display().to_string(),
4031            &redirect_url,
4032            &csp_nonce,
4033        );
4034    };
4035
4036    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4037    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4038    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4039
4040    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4041        redirect_url
4042    } else {
4043        "/compare-scans".to_string()
4044    };
4045    redirect_or_json_ok(want_json, &safe_redirect)
4046}
4047
4048fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4049    let mut out = Vec::new();
4050    collect_scan_files_by_ext(folder, ext, &mut out);
4051    if let Ok(rd) = fs::read_dir(folder) {
4052        for entry in rd.flatten() {
4053            let sub = entry.path();
4054            if sub.is_dir() {
4055                collect_scan_files_by_ext(&sub, ext, &mut out);
4056            }
4057        }
4058    }
4059    out
4060}
4061
4062fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4063    let Ok(rd) = fs::read_dir(dir) else { return };
4064    for entry in rd.flatten() {
4065        let p = entry.path();
4066        if p.is_file()
4067            && p.file_stem()
4068                .and_then(|n| n.to_str())
4069                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4070            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4071        {
4072            out.push(p);
4073        }
4074    }
4075}
4076
4077fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4078    candidates
4079        .iter()
4080        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4081        .cloned()
4082}
4083
4084/// Return the best folder hint for the relocate page.
4085/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
4086/// point at the parent — the actual top-level output directory — so the user
4087/// selects the root folder rather than the subfolder.
4088fn output_folder_hint(json_path: &std::path::Path) -> String {
4089    let Some(direct_parent) = json_path.parent() else {
4090        return String::new();
4091    };
4092    let parent_name = direct_parent
4093        .file_name()
4094        .and_then(|n| n.to_str())
4095        .unwrap_or("");
4096    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4097        direct_parent.parent().map_or_else(
4098            || direct_parent.display().to_string(),
4099            |p| p.display().to_string(),
4100        )
4101    } else {
4102        direct_parent.display().to_string()
4103    }
4104}
4105
4106async fn update_run_file_paths(
4107    state: &AppState,
4108    run_id: &str,
4109    json_path: PathBuf,
4110    html_path: Option<PathBuf>,
4111    pdf_path: Option<PathBuf>,
4112) {
4113    {
4114        let mut reg = state.registry.lock().await;
4115        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4116            entry.json_path = Some(json_path.clone());
4117            if let Some(ref hp) = html_path {
4118                entry.html_path = Some(hp.clone());
4119            }
4120            if let Some(ref pp) = pdf_path {
4121                entry.pdf_path = Some(pp.clone());
4122            }
4123        }
4124        let _ = reg.save(&state.registry_path);
4125    }
4126    // Also patch the in-memory artifacts map so the result page picks up the
4127    // new paths without requiring a server restart.
4128    {
4129        let mut map = state.artifacts.lock().await;
4130        if let Some(arts) = map.get_mut(run_id) {
4131            arts.json_path = Some(json_path);
4132            if let Some(hp) = html_path {
4133                arts.html_path = Some(hp);
4134            }
4135            if let Some(pp) = pdf_path {
4136                arts.pdf_path = Some(pp);
4137            }
4138        }
4139    }
4140}
4141
4142fn missing_scan_relocate_response(
4143    message: &str,
4144    run_id: &str,
4145    folder_hint: &str,
4146    redirect_url: &str,
4147    server_mode: bool,
4148    csp_nonce: &str,
4149) -> axum::response::Response {
4150    let html = RelocateScanTemplate {
4151        message: message.to_string(),
4152        run_id: run_id.to_string(),
4153        folder_hint: folder_hint.to_string(),
4154        redirect_url: redirect_url.to_string(),
4155        server_mode,
4156        csp_nonce: csp_nonce.to_owned(),
4157        version: env!("CARGO_PKG_VERSION"),
4158    }
4159    .render()
4160    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4161    (StatusCode::NOT_FOUND, Html(html)).into_response()
4162}
4163
4164// ── Watched-directory helpers ─────────────────────────────────────────────────
4165
4166/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
4167fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4168    fs::read_dir(dir)
4169        .ok()?
4170        .flatten()
4171        .map(|e| e.path())
4172        .find(|p| {
4173            p.is_file()
4174                && p.extension()
4175                    .and_then(|e| e.to_str())
4176                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4177        })
4178}
4179
4180/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
4181/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
4182/// (`<scan_dir>/json/result*.json`).
4183fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4184    let mut out = Vec::new();
4185    if let Some(j) = find_result_json_in_dir(sub) {
4186        out.push(j);
4187    }
4188    let json_sub = sub.join("json");
4189    if json_sub.is_dir()
4190        && let Some(j) = find_result_json_in_dir(&json_sub)
4191    {
4192        out.push(j);
4193    }
4194    out
4195}
4196
4197fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4198    let mut candidates = Vec::new();
4199    if let Some(j) = find_result_json_in_dir(folder) {
4200        candidates.push(j);
4201    }
4202    let Ok(dir_entries) = fs::read_dir(folder) else {
4203        return candidates;
4204    };
4205    for entry in dir_entries.flatten() {
4206        let sub = entry.path();
4207        if sub.is_dir() {
4208            candidates.extend(subdir_result_json_candidates(&sub));
4209        }
4210    }
4211    candidates
4212}
4213
4214fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4215    reg.entries.iter().any(|e| {
4216        let dir_match = e
4217            .json_path
4218            .as_ref()
4219            .and_then(|p| p.parent())
4220            .is_some_and(|p| p == parent)
4221            || e.html_path
4222                .as_ref()
4223                .and_then(|p| p.parent())
4224                .is_some_and(|p| p == parent);
4225        dir_match
4226            && (e.json_path.as_ref().is_some_and(|p| p.exists())
4227                || e.html_path.as_ref().is_some_and(|p| p.exists()))
4228    })
4229}
4230
4231fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4232    let json_dir = json_path.parent()?.to_path_buf();
4233    // If the JSON lives inside a directory named "json", the scan root is its parent
4234    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
4235    let (html_path, pdf_path, csv_path, xlsx_path) =
4236        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4237            let scan_root = json_dir.parent()?;
4238            let html = find_html_report_in_dir(&scan_root.join("html"))
4239                .or_else(|| find_html_report_in_dir(scan_root));
4240            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4241            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4242            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4243            (html, pdf, csv, xlsx)
4244        } else {
4245            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4246                rd.flatten()
4247                    .map(|e| e.path())
4248                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4249            });
4250            (html, None, None, None)
4251        };
4252    let run = read_json(&json_path).ok()?;
4253    let project_label = run.input_roots.first().map_or_else(
4254        || "Unknown Project".to_string(),
4255        |r| sanitize_project_label(r),
4256    );
4257    Some(RegistryEntry {
4258        run_id: run.tool.run_id.clone(),
4259        timestamp_utc: run.tool.timestamp_utc,
4260        project_label,
4261        input_roots: run.input_roots.clone(),
4262        json_path: Some(json_path),
4263        html_path,
4264        pdf_path,
4265        csv_path,
4266        xlsx_path,
4267        summary: ScanSummarySnapshot::from(&run.summary_totals),
4268        git_branch: run.git_branch.clone(),
4269        git_commit: run.git_commit_short.clone(),
4270        git_commit_long: run.git_commit_long.clone(),
4271        git_author: run.git_commit_author.clone(),
4272        git_tags: run.git_tags.clone(),
4273        git_nearest_tag: run.git_nearest_tag.clone(),
4274        git_commit_date: run.git_commit_date,
4275    })
4276}
4277
4278/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
4279/// Returns the number of newly linked entries.
4280fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4281    let mut linked = 0usize;
4282    for json_path in collect_result_json_candidates(folder) {
4283        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4284            continue;
4285        };
4286        if is_dir_already_registered(reg, &parent) {
4287            continue;
4288        }
4289        let Some(entry) = build_registry_entry_from_json(json_path) else {
4290            continue;
4291        };
4292        reg.add_entry(entry);
4293        linked += 1;
4294    }
4295    linked
4296}
4297
4298/// Scan all watched directories (plus the default output root) into `reg`.
4299async fn auto_scan_watched_dirs(state: &AppState) {
4300    let dirs: Vec<PathBuf> = {
4301        let wd = state.watched_dirs.lock().await;
4302        wd.dirs.clone()
4303    };
4304    // Reconcile the registry to the watched-folder model: keep only entries under a
4305    // currently-watched folder or the app's own output directory. This drops leftovers from
4306    // folders that have since been un-watched (which would otherwise linger in the list).
4307    {
4308        let output_root = resolve_output_root(None);
4309        let mut roots: Vec<PathBuf> = dirs.clone();
4310        if let Ok(canon) = fs::canonicalize(&output_root) {
4311            roots.push(strip_unc_prefix(canon));
4312        }
4313        roots.push(output_root);
4314        let mut reg = state.registry.lock().await;
4315        if reg.retain_under_roots(&roots) > 0 {
4316            let _ = reg.save(&state.registry_path);
4317        }
4318    }
4319    if dirs.is_empty() {
4320        return;
4321    }
4322    let mut reg = state.registry.lock().await;
4323    let mut total = 0usize;
4324    for dir in &dirs {
4325        if dir.is_dir() {
4326            total += scan_folder_into_registry(dir, &mut reg);
4327        }
4328    }
4329    if total > 0 {
4330        let _ = reg.save(&state.registry_path);
4331    }
4332}
4333
4334// ── Watched-dir route forms ───────────────────────────────────────────────────
4335
4336#[derive(Deserialize)]
4337struct WatchedDirForm {
4338    folder_path: String,
4339    #[serde(default = "default_redirect")]
4340    redirect_to: String,
4341}
4342
4343fn default_redirect() -> String {
4344    "/view-reports".to_string()
4345}
4346
4347#[derive(Deserialize)]
4348struct WatchedDirRefreshForm {
4349    #[serde(default = "default_redirect")]
4350    redirect_to: String,
4351}
4352
4353// ── Watched-dir helpers ───────────────────────────────────────────────────────
4354
4355/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
4356fn safe_redirect(dest: &str) -> &str {
4357    if dest.starts_with('/') { dest } else { "/" }
4358}
4359
4360// ── Watched-dir handlers ──────────────────────────────────────────────────────
4361
4362async fn add_watched_dir_handler(
4363    State(state): State<AppState>,
4364    Form(form): Form<WatchedDirForm>,
4365) -> impl IntoResponse {
4366    if state.server_mode {
4367        return StatusCode::NOT_FOUND.into_response();
4368    }
4369    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4370        strip_unc_prefix(p)
4371    } else {
4372        let dest = format!(
4373            "{}?error=Folder+not+found+or+path+is+invalid.",
4374            safe_redirect(&form.redirect_to)
4375        );
4376        return axum::response::Redirect::to(&dest).into_response();
4377    };
4378    if !folder.is_dir() {
4379        let dest = format!(
4380            "{}?error=Selected+path+is+not+a+directory.",
4381            safe_redirect(&form.redirect_to)
4382        );
4383        return axum::response::Redirect::to(&dest).into_response();
4384    }
4385
4386    // Persist the watched directory.
4387    {
4388        let mut wd = state.watched_dirs.lock().await;
4389        wd.add(folder.clone());
4390        let _ = wd.save(&state.watched_dirs_path);
4391    }
4392
4393    // Immediately scan the folder and add any new reports.
4394    let linked = {
4395        let mut reg = state.registry.lock().await;
4396        let n = scan_folder_into_registry(&folder, &mut reg);
4397        if n > 0 {
4398            let _ = reg.save(&state.registry_path);
4399        }
4400        n
4401    };
4402
4403    let dest = if linked > 0 {
4404        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4405    } else {
4406        format!(
4407            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4408            safe_redirect(&form.redirect_to)
4409        )
4410    };
4411    axum::response::Redirect::to(&dest).into_response()
4412}
4413
4414async fn remove_watched_dir_handler(
4415    State(state): State<AppState>,
4416    Form(form): Form<WatchedDirForm>,
4417) -> impl IntoResponse {
4418    if state.server_mode {
4419        return StatusCode::NOT_FOUND.into_response();
4420    }
4421    let folder = PathBuf::from(&form.folder_path);
4422    {
4423        let mut wd = state.watched_dirs.lock().await;
4424        wd.remove(&folder);
4425        let _ = wd.save(&state.watched_dirs_path);
4426    }
4427    // Drop any reports that were linked in from this folder so the list reflects the removal.
4428    {
4429        let mut reg = state.registry.lock().await;
4430        if reg.remove_entries_under(&folder) > 0 {
4431            let _ = reg.save(&state.registry_path);
4432        }
4433    }
4434    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4435}
4436
4437async fn refresh_watched_dirs_handler(
4438    State(state): State<AppState>,
4439    Form(form): Form<WatchedDirRefreshForm>,
4440) -> impl IntoResponse {
4441    if state.server_mode {
4442        return StatusCode::NOT_FOUND.into_response();
4443    }
4444    let dirs: Vec<PathBuf> = {
4445        let wd = state.watched_dirs.lock().await;
4446        wd.dirs.clone()
4447    };
4448    let mut total = 0usize;
4449    {
4450        let mut reg = state.registry.lock().await;
4451        reg.prune_stale();
4452        for dir in &dirs {
4453            if dir.is_dir() {
4454                total += scan_folder_into_registry(dir, &mut reg);
4455            }
4456        }
4457        let _ = reg.save(&state.registry_path);
4458    }
4459    let dest = if total > 0 {
4460        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4461    } else {
4462        safe_redirect(&form.redirect_to).to_owned()
4463    };
4464    axum::response::Redirect::to(&dest).into_response()
4465}
4466
4467#[derive(Debug, Deserialize)]
4468struct OpenPathQuery {
4469    path: Option<String>,
4470}
4471
4472fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4473    let mut ancestor = std::path::Path::new(raw);
4474    loop {
4475        match ancestor.parent() {
4476            Some(p) => {
4477                ancestor = p;
4478                if ancestor.is_dir() {
4479                    break;
4480                }
4481            }
4482            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4483        }
4484    }
4485    Ok(ancestor.to_path_buf())
4486}
4487
4488async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4489    match tokio::fs::canonicalize(raw).await {
4490        Ok(canonical) if canonical.is_file() => canonical
4491            .parent()
4492            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4493                Ok(p.to_path_buf())
4494            }),
4495        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4496        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4497        Err(_) => find_existing_ancestor(raw),
4498    }
4499}
4500
4501async fn open_path_handler(
4502    State(state): State<AppState>,
4503    Query(query): Query<OpenPathQuery>,
4504) -> impl IntoResponse {
4505    if state.server_mode {
4506        return Json(serde_json::json!({
4507            "server_mode_disabled": true,
4508            "message": "Opening a path in the file manager is only available in local desktop mode."
4509        }))
4510        .into_response();
4511    }
4512    // Skip the OS file-manager call in headless / CI environments.
4513    if std::env::var("SLOC_HEADLESS").is_ok() {
4514        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4515    }
4516    let raw = match query.path.as_deref() {
4517        Some(p) if !p.is_empty() => p,
4518        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4519    };
4520
4521    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4522    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4523    // so the file explorer still opens somewhere useful.
4524    let target = match resolve_open_target(raw).await {
4525        Ok(p) => p,
4526        Err((code, msg)) => return (code, msg).into_response(),
4527    };
4528
4529    #[cfg(target_os = "windows")]
4530    win_dialog_focus::open_folder_foreground(target);
4531    #[cfg(target_os = "macos")]
4532    let _ = std::process::Command::new("open")
4533        .arg(&target)
4534        .stdout(Stdio::null())
4535        .stderr(Stdio::null())
4536        .spawn();
4537    #[cfg(target_os = "linux")]
4538    {
4539        let folder_name = target
4540            .file_name()
4541            .and_then(|n| n.to_str())
4542            .map(str::to_owned);
4543        let _ = std::process::Command::new("xdg-open")
4544            .arg(&target)
4545            .stdout(Stdio::null())
4546            .stderr(Stdio::null())
4547            .spawn();
4548        // Best-effort: raise the file manager window once it appears.
4549        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4550        // installed; failures are silently discarded.
4551        if let Some(name) = folder_name {
4552            std::thread::spawn(move || {
4553                std::thread::sleep(std::time::Duration::from_millis(800));
4554                let _ = std::process::Command::new("wmctrl")
4555                    .args(["-a", &name])
4556                    .stdout(Stdio::null())
4557                    .stderr(Stdio::null())
4558                    .spawn();
4559            });
4560        }
4561    }
4562
4563    Json(serde_json::json!({"ok": true})).into_response()
4564}
4565
4566async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4567    let (content_type, bytes): (&'static str, &'static [u8]) =
4568        match (folder.as_str(), file.as_str()) {
4569            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4570            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4571            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4572            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4573            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4574            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4575            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4576            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4577            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4578            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4579            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4580            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4581            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4582            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4583            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4584            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4585            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4586            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4587            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4588            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4589            _ => return StatusCode::NOT_FOUND.into_response(),
4590        };
4591    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4592}
4593
4594/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4595/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4596/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4597/// complexity low; the fail-closed semantics are unchanged.
4598fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4599    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4600    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4601    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4602    // sample/upload locations are permitted; everything else is rejected.
4603    let Ok(canonical) = fs::canonicalize(resolved) else {
4604        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4605            return Err(Html(
4606                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4607            ));
4608        }
4609        return Ok(());
4610    };
4611    // Upload temp dirs and built-in sample/fixture paths are always safe.
4612    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4613        return Ok(());
4614    }
4615    let config = &state.base_config;
4616    if config.discovery.allowed_scan_roots.is_empty() {
4617        return Err(Html(
4618            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()
4619        ));
4620    }
4621    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4622        fs::canonicalize(root)
4623            .ok()
4624            .is_some_and(|r| canonical.starts_with(&r))
4625    });
4626    if !allowed {
4627        return Err(Html(
4628            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4629        ));
4630    }
4631    Ok(())
4632}
4633
4634async fn preview_handler(
4635    State(state): State<AppState>,
4636    Query(query): Query<PreviewQuery>,
4637) -> impl IntoResponse {
4638    let raw_path = query
4639        .path
4640        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4641    let resolved = resolve_input_path(&raw_path);
4642
4643    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4644    // binary whose working directory is not the project root), return a clear message
4645    // instead of an opaque OS error from build_preview_html.
4646    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4647        return Html(
4648            r#"<div class="preview-error">Sample directory not available on this server.
4649            Enter a path to a project directory or upload files using Browse.</div>"#
4650                .to_string(),
4651        );
4652    }
4653
4654    if state.server_mode
4655        && let Err(resp) = authorize_preview_path(&state, &resolved)
4656    {
4657        return resp;
4658    }
4659
4660    let include_patterns = split_patterns(query.include_globs.as_deref());
4661    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4662
4663    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4664        Ok(html) => Html(html),
4665        Err(err) => Html(format!(
4666            r#"<div class="preview-error">Preview failed: {}</div>"#,
4667            escape_html(&err.to_string())
4668        )),
4669    }
4670}
4671
4672#[derive(Debug, Deserialize, Default)]
4673struct SuggestCoverageQuery {
4674    path: Option<String>,
4675}
4676
4677#[derive(Serialize)]
4678struct SuggestCoverageResponse {
4679    found: Option<String>,
4680    tool: Option<&'static str>,
4681    hint: Option<&'static str>,
4682}
4683
4684async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4685    const CANDIDATES: &[&str] = &[
4686        // LCOV — cargo-llvm-cov, gcov, lcov
4687        "coverage/lcov.info",
4688        "lcov.info",
4689        "target/llvm-cov/lcov.info",
4690        "target/coverage/lcov.info",
4691        "target/debug/coverage/lcov.info",
4692        "coverage/coverage.lcov",
4693        "build/coverage/lcov.info",
4694        "reports/lcov.info",
4695        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4696        "coverage.xml",
4697        "coverage/coverage.xml",
4698        "target/site/cobertura/coverage.xml",
4699        "build/reports/coverage/coverage.xml",
4700        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4701        "target/site/jacoco/jacoco.xml",
4702        "build/reports/jacoco/test/jacocoTestReport.xml",
4703        "build/reports/jacoco/jacocoTestReport.xml",
4704        "build/jacoco/jacoco.xml",
4705        // coverage.py native JSON — `coverage json`
4706        "coverage.json",
4707        "coverage/coverage.json",
4708    ];
4709    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4710    let found = CANDIDATES
4711        .iter()
4712        .map(|rel| root.join(rel))
4713        .find(|p| p.is_file())
4714        .map(|p| display_path(&p));
4715
4716    let (tool, hint) = detect_coverage_tool(&root);
4717    Json(SuggestCoverageResponse { found, tool, hint })
4718}
4719
4720/// Inspect the project root for known build/package files and return the most likely coverage
4721/// tool name and the shell command needed to generate a coverage file.
4722fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4723    if root.join("Cargo.toml").is_file() {
4724        return (
4725            Some("cargo-llvm-cov"),
4726            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4727        );
4728    }
4729    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4730        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4731    }
4732    if root.join("pom.xml").is_file() {
4733        return (Some("jacoco"), Some("mvn test jacoco:report"));
4734    }
4735    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4736        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4737    }
4738    (None, None)
4739}
4740
4741/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4742#[allow(clippy::result_large_err)]
4743fn validate_server_scan_path(
4744    config: &sloc_config::AppConfig,
4745    resolved_path: &Path,
4746    csp_nonce: &str,
4747) -> Result<(), Response> {
4748    if config.discovery.allowed_scan_roots.is_empty() {
4749        let template = ErrorTemplate {
4750            message: "Scan path rejected: this server has no scan roots configured, so \
4751                      scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4752                      environment variable (colon-separated absolute paths) — or \
4753                      allowed_scan_roots in the config TOML — then restart. Tip: the \
4754                      Browse / directory-upload flow works without this; uploaded folders \
4755                      are scanned from the server's temp area and bypass this check."
4756                .to_string(),
4757            last_report_url: None,
4758            last_report_label: None,
4759            run_id: None,
4760            error_code: Some(403),
4761            csp_nonce: csp_nonce.to_owned(),
4762            version: env!("CARGO_PKG_VERSION"),
4763        };
4764        return Err((
4765            StatusCode::FORBIDDEN,
4766            Html(
4767                template
4768                    .render()
4769                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4770            ),
4771        )
4772            .into_response());
4773    }
4774    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4775    // location) we must NOT fall back to the raw, un-normalised path — a textual
4776    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4777    // allowlist. A non-resolvable scan target is rejected outright.
4778    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4779        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4780            "Scan path does not resolve to a real location");
4781        let template = ErrorTemplate {
4782            message: "The requested path could not be resolved to a real directory.".to_string(),
4783            last_report_url: None,
4784            last_report_label: None,
4785            run_id: None,
4786            error_code: Some(403),
4787            csp_nonce: csp_nonce.to_owned(),
4788            version: env!("CARGO_PKG_VERSION"),
4789        };
4790        return Err((
4791            StatusCode::FORBIDDEN,
4792            Html(
4793                template
4794                    .render()
4795                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4796            ),
4797        )
4798            .into_response());
4799    };
4800    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4801        fs::canonicalize(root)
4802            .ok()
4803            .is_some_and(|r| canonical.starts_with(&r))
4804    });
4805    if !allowed {
4806        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4807            "Scan path not in allowed_scan_roots");
4808        let template = ErrorTemplate {
4809            message: "The requested path is not within an allowed scan directory.".to_string(),
4810            last_report_url: None,
4811            last_report_label: None,
4812            run_id: None,
4813            error_code: Some(403),
4814            csp_nonce: csp_nonce.to_owned(),
4815            version: env!("CARGO_PKG_VERSION"),
4816        };
4817        return Err((
4818            StatusCode::FORBIDDEN,
4819            Html(
4820                template
4821                    .render()
4822                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4823            ),
4824        )
4825            .into_response());
4826    }
4827    Ok(())
4828}
4829
4830/// Exclude the output directory from scanning so artifacts don't pollute counts.
4831fn apply_output_dir_exclusions(
4832    config: &mut sloc_config::AppConfig,
4833    project_path: &str,
4834    raw_output_dir: &str,
4835) {
4836    let project_root = resolve_input_path(project_path);
4837    let raw_out = raw_output_dir.trim();
4838    let resolved_out = if raw_out.is_empty() {
4839        project_root.join("sloc")
4840    } else if Path::new(raw_out).is_absolute() {
4841        PathBuf::from(raw_out)
4842    } else {
4843        workspace_root().join(raw_out)
4844    };
4845    if let Ok(rel) = resolved_out.strip_prefix(&project_root)
4846        && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
4847    {
4848        let dir = first.to_string();
4849        if !config.discovery.excluded_directories.contains(&dir) {
4850            config.discovery.excluded_directories.push(dir);
4851        }
4852    }
4853    if !config
4854        .discovery
4855        .excluded_directories
4856        .iter()
4857        .any(|d| d == "sloc")
4858    {
4859        config
4860            .discovery
4861            .excluded_directories
4862            .push("sloc".to_string());
4863    }
4864}
4865
4866/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4867const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4868    ScanSummarySnapshot {
4869        files_analyzed: run.summary_totals.files_analyzed,
4870        files_skipped: run.summary_totals.files_skipped,
4871        total_physical_lines: run.summary_totals.total_physical_lines,
4872        code_lines: run.summary_totals.code_lines,
4873        comment_lines: run.summary_totals.comment_lines,
4874        blank_lines: run.summary_totals.blank_lines,
4875        functions: run.summary_totals.functions,
4876        classes: run.summary_totals.classes,
4877        variables: run.summary_totals.variables,
4878        imports: run.summary_totals.imports,
4879        test_count: run.summary_totals.test_count,
4880        coverage_lines_found: run.summary_totals.coverage_lines_found,
4881        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4882        coverage_functions_found: run.summary_totals.coverage_functions_found,
4883        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4884        coverage_branches_found: run.summary_totals.coverage_branches_found,
4885        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4886    }
4887}
4888
4889/// Build the `RegistryEntry` for the just-completed scan run.
4890pub(crate) fn build_run_registry_entry(
4891    run: &AnalysisRun,
4892    run_id: &str,
4893    project_label: &str,
4894    artifacts: &RunArtifacts,
4895) -> RegistryEntry {
4896    RegistryEntry {
4897        run_id: run_id.to_owned(),
4898        timestamp_utc: run.tool.timestamp_utc,
4899        project_label: project_label.to_owned(),
4900        input_roots: run.input_roots.clone(),
4901        json_path: artifacts.json_path.clone(),
4902        html_path: artifacts.html_path.clone(),
4903        pdf_path: artifacts.pdf_path.clone(),
4904        csv_path: artifacts.csv_path.clone(),
4905        xlsx_path: artifacts.xlsx_path.clone(),
4906        summary: summary_snapshot_from_run(run),
4907        git_branch: run.git_branch.clone(),
4908        git_commit: run.git_commit_short.clone(),
4909        git_commit_long: run.git_commit_long.clone(),
4910        git_author: run.git_commit_author.clone(),
4911        git_tags: run.git_tags.clone(),
4912        git_nearest_tag: run.git_nearest_tag.clone(),
4913        git_commit_date: run.git_commit_date.clone(),
4914    }
4915}
4916
4917/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4918fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4919    if let Some(policy) = form.mixed_line_policy {
4920        config.analysis.mixed_line_policy = policy;
4921    }
4922    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4923    config.analysis.generated_file_detection =
4924        form.generated_file_detection.as_deref() != Some("disabled");
4925    config.analysis.minified_file_detection =
4926        form.minified_file_detection.as_deref() != Some("disabled");
4927    config.analysis.vendor_directory_detection =
4928        form.vendor_directory_detection.as_deref() != Some("disabled");
4929    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4930    if let Some(binary_behavior) = form.binary_file_behavior {
4931        config.analysis.binary_file_behavior = binary_behavior;
4932    }
4933    apply_report_opts(config, form);
4934    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4935    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4936    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4937    if let Some(policy) = form.continuation_line_policy {
4938        config.analysis.continuation_line_policy = policy;
4939    }
4940    if let Some(policy) = form.blank_in_block_comment_policy {
4941        config.analysis.blank_in_block_comment_policy = policy;
4942    }
4943    config.analysis.count_compiler_directives =
4944        form.count_compiler_directives.as_deref() != Some("disabled");
4945    apply_style_threshold(config, form);
4946    apply_coverage_path(config, form);
4947}
4948
4949fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4950    if let Some(report_title) = form.report_title.as_deref() {
4951        let trimmed = report_title.trim();
4952        if !trimmed.is_empty() {
4953            config.reporting.report_title = trimmed.to_string();
4954        }
4955    }
4956    if let Some(hf) = form.report_header_footer.as_deref() {
4957        let trimmed = hf.trim();
4958        config.reporting.report_header_footer = if trimmed.is_empty() {
4959            None
4960        } else {
4961            Some(trimmed.to_string())
4962        };
4963    }
4964}
4965
4966fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4967    apply_style_col_threshold(config, form);
4968    apply_style_analysis_enabled(config, form);
4969    apply_style_score_threshold(config, form);
4970    apply_style_lang_scope(config, form);
4971    apply_activity_window(config, form);
4972}
4973
4974fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4975    if let Some(threshold_str) = form.style_col_threshold.as_deref()
4976        && let Ok(t) = threshold_str.parse::<u16>()
4977        && (t == 80 || t == 100 || t == 120)
4978    {
4979        config.analysis.style_col_threshold = t;
4980    }
4981}
4982
4983fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4984    if let Some(v) = form.style_analysis_enabled.as_deref() {
4985        config.analysis.style_analysis_enabled = v != "disabled";
4986    }
4987}
4988
4989fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4990    if let Some(v) = form.style_score_threshold.as_deref()
4991        && let Ok(t) = v.parse::<u8>()
4992    {
4993        config.analysis.style_score_threshold = t.min(100);
4994    }
4995}
4996
4997fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4998    if let Some(v) = form.style_lang_scope.as_deref() {
4999        let scope = v.trim();
5000        if scope == "c_family" || scope == "all" {
5001            config.analysis.style_lang_scope = scope.to_string();
5002        }
5003    }
5004}
5005
5006fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5007    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
5008    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
5009    if let Some(w) = form.activity_window.as_deref() {
5010        let w = w.trim();
5011        if !w.is_empty()
5012            && let Ok(days) = w.parse::<u32>()
5013        {
5014            config.analysis.activity_window_days = Some(days);
5015        }
5016    }
5017}
5018
5019fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5020    if let Some(cov) = &form.coverage_file {
5021        let trimmed = cov.trim();
5022        if !trimmed.is_empty() {
5023            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5024        }
5025    }
5026}
5027
5028/// Fire-and-forget: generate the PDF in a background task if one is pending.
5029/// On failure, clears `pdf_path` in the artifacts map so the results page shows
5030/// an error instead of spinning indefinitely.
5031fn spawn_pdf_background(
5032    pending_pdf: PendingPdf,
5033    run_id: String,
5034    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5035) {
5036    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5037        tokio::spawn(async move {
5038            let result = tokio::task::spawn_blocking(move || {
5039                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5040                if cleanup_src {
5041                    let _ = fs::remove_file(&pdf_src);
5042                }
5043                r
5044            })
5045            .await;
5046            let failed = match result {
5047                Ok(Ok(())) => false,
5048                Ok(Err(err)) => {
5049                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5050                    true
5051                }
5052                Err(err) => {
5053                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5054                    true
5055                }
5056            };
5057            if failed {
5058                let mut map = artifacts.lock().await;
5059                if let Some(entry) = map.get_mut(&run_id) {
5060                    entry.pdf_path = None;
5061                }
5062            }
5063        });
5064    }
5065}
5066
5067/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
5068/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
5069/// result page can show an error on the next visit instead of spinning indefinitely.
5070fn spawn_native_pdf_background(
5071    json_path: PathBuf,
5072    pdf_dest: PathBuf,
5073    run_id: String,
5074    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5075) {
5076    tokio::spawn(async move {
5077        let result = tokio::task::spawn_blocking(move || {
5078            let run = sloc_core::read_json(&json_path)?;
5079            write_pdf_from_run(&run, &pdf_dest)
5080        })
5081        .await;
5082        let failed = match result {
5083            Ok(Ok(())) => false,
5084            Ok(Err(err)) => {
5085                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5086                true
5087            }
5088            Err(err) => {
5089                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5090                true
5091            }
5092        };
5093        if failed {
5094            let mut map = artifacts.lock().await;
5095            if let Some(entry) = map.get_mut(&run_id) {
5096                entry.pdf_path = None;
5097            }
5098        }
5099    });
5100}
5101
5102/// Sum the code lines added in this comparison (new + grown files).
5103fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5104    cmp.file_deltas
5105        .iter()
5106        .map(|f| match f.status {
5107            FileChangeStatus::Added => f.current_code,
5108            FileChangeStatus::Modified => f.code_delta.max(0),
5109            _ => 0,
5110        })
5111        .sum()
5112}
5113
5114/// Sum the code lines removed in this comparison (deleted + shrunk files).
5115fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5116    cmp.file_deltas
5117        .iter()
5118        .map(|f| match f.status {
5119            FileChangeStatus::Removed => f.baseline_code,
5120            FileChangeStatus::Modified => (-f.code_delta).max(0),
5121            _ => 0,
5122        })
5123        .sum()
5124}
5125
5126/// Sum the code lines present in both scans without any change (Unchanged files).
5127fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5128    cmp.file_deltas
5129        .iter()
5130        .filter(|f| f.status == FileChangeStatus::Unchanged)
5131        .map(|f| f.current_code)
5132        .sum()
5133}
5134
5135/// Sum the code lines residing in files that were modified between the two scans.
5136fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5137    cmp.file_deltas
5138        .iter()
5139        .filter(|f| f.status == FileChangeStatus::Modified)
5140        .map(|f| f.current_code)
5141        .sum()
5142}
5143
5144/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
5145fn build_submodule_row(
5146    s: &sloc_core::SubmoduleSummary,
5147    run: &AnalysisRun,
5148    run_id: &str,
5149    run_dir: &Path,
5150) -> SubmoduleRow {
5151    let safe = sanitize_project_label(&s.name);
5152    let artifact_key = format!("sub_{safe}");
5153    let pdf_artifact_key = format!("sub_{safe}_pdf");
5154    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5155        let parent_path = run
5156            .input_roots
5157            .first()
5158            .map_or("", std::string::String::as_str);
5159        let sub_run = build_sub_run(run, s, parent_path);
5160        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5161        render_sub_report_html(&sub_run, Some(&pdf_server_url))
5162            .ok()
5163            .and_then(|sub_html| {
5164                let sub_dir = run_dir.join("submodules");
5165                let _ = fs::create_dir_all(&sub_dir);
5166                let html_path = sub_dir.join(format!("{artifact_key}.html"));
5167                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5168                    // Pre-generate the sub-report PDF using the programmatic renderer
5169                    // so "View PDF" never needs to spawn Chrome for submodules.
5170                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5171                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
5172                    Some(format!("/runs/{artifact_key}/{run_id}"))
5173                } else {
5174                    None
5175                }
5176            })
5177    } else {
5178        None
5179    };
5180    SubmoduleRow {
5181        name: s.name.clone(),
5182        relative_path: s.relative_path.clone(),
5183        files_analyzed: s.files_analyzed,
5184        code_lines: s.code_lines,
5185        comment_lines: s.comment_lines,
5186        blank_lines: s.blank_lines,
5187        total_physical_lines: s.total_physical_lines,
5188        html_url,
5189    }
5190}
5191
5192// Immediately returns a wait page and runs the analysis in a background tokio task.
5193// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
5194#[allow(clippy::similar_names)]
5195#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
5196#[allow(clippy::too_many_lines)]
5197async fn analyze_handler(
5198    State(state): State<AppState>,
5199    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5200    Form(form): Form<AnalyzeForm>,
5201) -> impl IntoResponse {
5202    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5203        let template = ErrorTemplate {
5204            message: format!(
5205                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5206             Please wait a moment and try again."
5207            ),
5208            last_report_url: None,
5209            last_report_label: None,
5210            run_id: None,
5211            error_code: Some(503),
5212            csp_nonce: csp_nonce.clone(),
5213            version: env!("CARGO_PKG_VERSION"),
5214        };
5215        return (
5216            StatusCode::SERVICE_UNAVAILABLE,
5217            Html(
5218                template
5219                    .render()
5220                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5221            ),
5222        )
5223            .into_response();
5224    };
5225
5226    let mut config = state.base_config.clone();
5227
5228    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5229    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5230    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5231
5232    if !is_git_mode {
5233        let resolved_path = resolve_input_path(&form.path);
5234        if state.server_mode
5235            && !is_upload_tmp_path(&resolved_path)
5236            && !is_sample_path(&resolved_path)
5237            && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
5238        {
5239            return resp;
5240        }
5241        config.discovery.root_paths = vec![resolved_path];
5242    }
5243
5244    apply_form_to_config(&mut config, &form);
5245    apply_output_dir_exclusions(
5246        &mut config,
5247        &form.path,
5248        form.output_dir.as_deref().unwrap_or(""),
5249    );
5250
5251    // Generate a wait_id now (before spawning) so the client can poll for status.
5252    let wait_id = uuid::Uuid::new_v4().to_string();
5253    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5254
5255    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
5256    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5257    let task_cancel = Arc::clone(&cancel_token);
5258
5259    // Phase tracker: updated by run_analysis_task at key checkpoints.
5260    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5261    let task_phase = Arc::clone(&phase);
5262
5263    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5264    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5265    let task_files_done = Arc::clone(&files_done);
5266    let task_files_total = Arc::clone(&files_total);
5267
5268    // Register Running state before building the task struct so the semaphore permit
5269    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
5270    {
5271        let mut runs = state.async_runs.lock().await;
5272        runs.insert(
5273            wait_id.clone(),
5274            AsyncRunState::Running {
5275                started_at: std::time::Instant::now(),
5276                cancel_token,
5277                phase,
5278                files_done,
5279                files_total,
5280            },
5281        );
5282    }
5283
5284    let task = AnalysisTask {
5285        sem_permit,
5286        state: state.clone(),
5287        wait_id: wait_id.clone(),
5288        config,
5289        cancel: task_cancel,
5290        phase: task_phase,
5291        files_done: task_files_done,
5292        files_total: task_files_total,
5293        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5294        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5295        project_path: form.path.clone(),
5296        // In server mode the client-supplied output_dir is ignored — artifacts are
5297        // always written under the server's configured output root so remote users
5298        // cannot direct writes to arbitrary filesystem paths.
5299        output_dir: if state.server_mode {
5300            None
5301        } else {
5302            form.output_dir.clone()
5303        },
5304        clones_dir: state.git_clones_dir.clone(),
5305        cocomo_mode: form
5306            .cocomo_mode
5307            .clone()
5308            .unwrap_or_else(|| "organic".to_string()),
5309        complexity_alert: form
5310            .complexity_alert
5311            .as_deref()
5312            .and_then(|s| s.parse::<u32>().ok())
5313            .unwrap_or(0),
5314        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5315    };
5316
5317    tokio::spawn(run_analysis_task(task));
5318
5319    let template = ScanWaitTemplate {
5320        version: env!("CARGO_PKG_VERSION"),
5321        wait_id_json,
5322        project_path: form.path.clone(),
5323        csp_nonce,
5324    };
5325    let html = template
5326        .render()
5327        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5328    let mut response = Html(html).into_response();
5329    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
5330        && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
5331    {
5332        response.headers_mut().insert(name, val);
5333    }
5334    response
5335}
5336
5337struct AnalysisTask {
5338    sem_permit: tokio::sync::OwnedSemaphorePermit,
5339    state: AppState,
5340    wait_id: String,
5341    config: AppConfig,
5342    cancel: Arc<std::sync::atomic::AtomicBool>,
5343    phase: Arc<std::sync::Mutex<String>>,
5344    files_done: Arc<std::sync::atomic::AtomicUsize>,
5345    files_total: Arc<std::sync::atomic::AtomicUsize>,
5346    git_repo: Option<String>,
5347    git_ref: Option<String>,
5348    project_path: String,
5349    output_dir: Option<String>,
5350    clones_dir: PathBuf,
5351    cocomo_mode: String,
5352    complexity_alert: u32,
5353    exclude_duplicates: bool,
5354}
5355
5356#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
5357async fn run_analysis_task(task: AnalysisTask) {
5358    let _permit = task.sem_permit;
5359
5360    let cancel_sb = Arc::clone(&task.cancel);
5361    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5362    let clones_dir_sb = task.clones_dir;
5363    // Save the upload staging path before config is moved into spawn_blocking.
5364    let upload_staging_root = task
5365        .config
5366        .discovery
5367        .root_paths
5368        .first()
5369        .filter(|p| is_upload_tmp_path(p))
5370        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5371        .map(PathBuf::from);
5372    let config_sb = task.config;
5373    let progress_sb = sloc_core::ProgressCounters {
5374        files_done: Arc::clone(&task.files_done),
5375        files_total: Arc::clone(&task.files_total),
5376    };
5377    if let Ok(mut p) = task.phase.lock() {
5378        *p = "Scanning files".to_string();
5379    }
5380    let analysis_result = tokio::task::spawn_blocking(move || {
5381        run_analysis_blocking(
5382            config_sb,
5383            git_repo_sb,
5384            git_ref_sb,
5385            clones_dir_sb,
5386            cancel_sb,
5387            Some(progress_sb),
5388        )
5389    })
5390    .await
5391    .map_err(|err| anyhow::anyhow!(err.to_string()))
5392    .and_then(|result| result);
5393
5394    if let Ok(mut p) = task.phase.lock() {
5395        *p = "Writing reports".to_string();
5396    }
5397
5398    // If cancelled while running, discard results and mark as cancelled.
5399    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5400        let mut runs = task.state.async_runs.lock().await;
5401        // Only overwrite if still Running (don't clobber a Complete that snuck in).
5402        if matches!(
5403            runs.get(&task.wait_id),
5404            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5405        ) {
5406            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5407        }
5408        drop(runs);
5409        return;
5410    }
5411
5412    let run = match analysis_result {
5413        Ok(v) => v,
5414        Err(err) => {
5415            // Distinguish user-cancelled from real failure.
5416            if err.to_string().contains("analysis cancelled") {
5417                let mut runs = task.state.async_runs.lock().await;
5418                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5419                drop(runs);
5420                return;
5421            }
5422            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5423            let mut runs = task.state.async_runs.lock().await;
5424            runs.insert(
5425                task.wait_id.clone(),
5426                AsyncRunState::Failed {
5427                    message: "Analysis failed. Check that the path exists and is readable."
5428                        .to_string(),
5429                },
5430            );
5431            drop(runs);
5432            return;
5433        }
5434    };
5435
5436    let run_id = run.tool.run_id.clone();
5437    tracing::info!(event = "scan_complete", run_id = %run_id,
5438        path = %task.project_path, files = run.summary_totals.files_analyzed,
5439        "Analysis finished");
5440
5441    let prev_entry: Option<RegistryEntry> = {
5442        let reg = task.state.registry.lock().await;
5443        reg.entries_for_roots(&run.input_roots)
5444            .into_iter()
5445            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5446            .cloned()
5447    };
5448
5449    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5450        prev.json_path
5451            .as_ref()
5452            .and_then(|p| read_json(p).ok())
5453            .map(|prev_run| compute_delta(&prev_run, &run))
5454    });
5455    let prev_scan_count: usize = {
5456        let reg = task.state.registry.lock().await;
5457        reg.entries_for_roots(&run.input_roots)
5458            .iter()
5459            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5460            .count()
5461    };
5462
5463    // Build the HTML report now that delta is available, so the artifact
5464    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5465    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5466        .as_ref()
5467        .zip(prev_entry.as_ref())
5468        .map(|(cmp, prev)| ReportDeltaContext {
5469            delta_code_added: sum_added_code_lines(cmp),
5470            delta_code_removed: sum_removed_code_lines(cmp),
5471            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5472            delta_files_added: cmp.files_added,
5473            delta_files_removed: cmp.files_removed,
5474            delta_files_modified: cmp.files_modified,
5475            delta_files_unchanged: cmp.files_unchanged,
5476            prev_code_lines: prev.summary.code_lines,
5477            prev_scan_count: prev_scan_count + 1,
5478            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5479            prev_run_id: Some(prev.run_id.clone()),
5480            current_run_id: Some(run_id.clone()),
5481        });
5482    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5483        Ok(h) => h,
5484        Err(err) => {
5485            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5486            let mut runs = task.state.async_runs.lock().await;
5487            runs.insert(
5488                task.wait_id.clone(),
5489                AsyncRunState::Failed {
5490                    message: "Failed to render HTML report.".to_string(),
5491                },
5492            );
5493            drop(runs);
5494            return;
5495        }
5496    };
5497
5498    let output_root = resolve_output_root(task.output_dir.as_deref());
5499    let project_label = derive_project_label(
5500        task.git_repo.as_deref(),
5501        task.git_ref.as_deref(),
5502        &task.project_path,
5503    );
5504    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5505    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5506
5507    let result_context = RunResultContext {
5508        prev_entry: prev_entry.clone(),
5509        prev_scan_count,
5510        project_path: task.project_path.clone(),
5511        cocomo_mode: task.cocomo_mode.clone(),
5512        complexity_alert: task.complexity_alert,
5513        exclude_duplicates: task.exclude_duplicates,
5514    };
5515
5516    let artifact_result = persist_run_artifacts(
5517        &run,
5518        &report_html,
5519        &run_dir,
5520        &run.effective_configuration.reporting.report_title,
5521        &file_stem,
5522        result_context,
5523    );
5524
5525    let (artifacts, pending_pdf) = match artifact_result {
5526        Ok(v) => v,
5527        Err(err) => {
5528            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5529            let mut runs = task.state.async_runs.lock().await;
5530            runs.insert(
5531                task.wait_id.clone(),
5532                AsyncRunState::Failed {
5533                    message: "Failed to save report artifacts. Check available disk space."
5534                        .to_string(),
5535                },
5536            );
5537            drop(runs);
5538            return;
5539        }
5540    };
5541
5542    {
5543        let mut map = task.state.artifacts.lock().await;
5544        map.insert(run_id.clone(), artifacts.clone());
5545    }
5546
5547    {
5548        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5549        let mut reg = task.state.registry.lock().await;
5550        reg.add_entry(entry);
5551        let _ = reg.save(&task.state.registry_path);
5552    }
5553
5554    if let Some(ref cfg_path) = artifacts.scan_config_path {
5555        save_scan_config_json(
5556            cfg_path,
5557            &run,
5558            &task.project_path,
5559            task.output_dir.as_deref(),
5560            &task.cocomo_mode,
5561            task.complexity_alert,
5562            task.exclude_duplicates,
5563        );
5564    }
5565
5566    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5567
5568    prom_runs_total().inc();
5569
5570    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5571    let mut runs = task.state.async_runs.lock().await;
5572    runs.insert(
5573        task.wait_id.clone(),
5574        AsyncRunState::Complete {
5575            run_id: run_id.clone(),
5576        },
5577    );
5578    drop(runs);
5579
5580    // Remove the client-upload staging directory after a successful scan so
5581    // that uploaded project files don't accumulate in the OS temp directory.
5582    if let Some(staging) = upload_staging_root {
5583        let _ = tokio::fs::remove_dir_all(staging).await;
5584    }
5585
5586    let _ = scan_delta;
5587}
5588
5589fn save_scan_config_json(
5590    cfg_path: &std::path::Path,
5591    run: &sloc_core::AnalysisRun,
5592    project_path: &str,
5593    output_dir: Option<&str>,
5594    cocomo_mode: &str,
5595    complexity_alert: u32,
5596    exclude_duplicates: bool,
5597) {
5598    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5599        .ok()
5600        .and_then(|v| v.as_str().map(String::from))
5601        .unwrap_or_else(|| "code_only".to_string());
5602    let behavior_str =
5603        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5604            .ok()
5605            .and_then(|v| v.as_str().map(String::from))
5606            .unwrap_or_else(|| "skip".to_string());
5607    let continuation_policy_str = serde_json::to_value(
5608        run.effective_configuration
5609            .analysis
5610            .continuation_line_policy,
5611    )
5612    .ok()
5613    .and_then(|v| v.as_str().map(String::from))
5614    .unwrap_or_else(default_each_physical_line);
5615    let blank_policy_str = serde_json::to_value(
5616        run.effective_configuration
5617            .analysis
5618            .blank_in_block_comment_policy,
5619    )
5620    .ok()
5621    .and_then(|v| v.as_str().map(String::from))
5622    .unwrap_or_else(default_count_as_comment);
5623    let scan_cfg = ScanConfig {
5624        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5625        path: project_path.to_string(),
5626        include_globs: run
5627            .effective_configuration
5628            .discovery
5629            .include_globs
5630            .join("\n"),
5631        exclude_globs: run
5632            .effective_configuration
5633            .discovery
5634            .exclude_globs
5635            .join("\n"),
5636        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5637        mixed_line_policy: policy_str,
5638        python_docstrings_as_comments: run
5639            .effective_configuration
5640            .analysis
5641            .python_docstrings_as_comments,
5642        generated_file_detection: run
5643            .effective_configuration
5644            .analysis
5645            .generated_file_detection,
5646        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5647        vendor_directory_detection: run
5648            .effective_configuration
5649            .analysis
5650            .vendor_directory_detection,
5651        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5652        binary_file_behavior: behavior_str,
5653        output_dir: output_dir.unwrap_or("").to_string(),
5654        report_title: run.effective_configuration.reporting.report_title.clone(),
5655        continuation_line_policy: continuation_policy_str,
5656        blank_in_block_comment_policy: blank_policy_str,
5657        count_compiler_directives: run
5658            .effective_configuration
5659            .analysis
5660            .count_compiler_directives,
5661        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5662        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5663        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5664        style_lang_scope: run
5665            .effective_configuration
5666            .analysis
5667            .style_lang_scope
5668            .clone(),
5669        coverage_file: run
5670            .effective_configuration
5671            .analysis
5672            .coverage_file
5673            .as_ref()
5674            .map(|p| p.display().to_string())
5675            .unwrap_or_default(),
5676        cocomo_mode: cocomo_mode.to_string(),
5677        complexity_alert,
5678        exclude_duplicates,
5679        activity_window: run
5680            .effective_configuration
5681            .analysis
5682            .activity_window_days
5683            .unwrap_or(0),
5684    };
5685    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5686        let _ = std::fs::write(cfg_path, json);
5687    }
5688}
5689
5690#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5691fn run_analysis_blocking(
5692    mut config: AppConfig,
5693    git_repo: Option<String>,
5694    git_ref: Option<String>,
5695    clones_dir: PathBuf,
5696    cancel: Arc<std::sync::atomic::AtomicBool>,
5697    progress: Option<sloc_core::ProgressCounters>,
5698) -> Result<sloc_core::AnalysisRun> {
5699    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5700        let dest = git_clone_dest(&repo, &clones_dir);
5701        sloc_git::clone_or_fetch(&repo, &dest)?;
5702        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5703        sloc_git::create_worktree(&dest, &refname, &wt)?;
5704        config.discovery.root_paths = vec![wt.clone()];
5705        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5706        let _ = sloc_git::destroy_worktree(&dest, &wt);
5707        let mut run = run?;
5708        if run.git_branch.is_none() {
5709            run.git_branch = Some(refname);
5710        }
5711        return Ok(run);
5712    }
5713    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5714}
5715
5716fn derive_project_label(
5717    git_repo: Option<&str>,
5718    git_ref: Option<&str>,
5719    fallback_path: &str,
5720) -> String {
5721    match (
5722        git_repo.filter(|s| !s.is_empty()),
5723        git_ref.filter(|s| !s.is_empty()),
5724    ) {
5725        (Some(repo), Some(refname)) => {
5726            let repo_name = repo
5727                .trim_end_matches('/')
5728                .trim_end_matches(".git")
5729                .rsplit('/')
5730                .next()
5731                .unwrap_or("repo");
5732            sanitize_project_label(&format!("{repo_name}_{refname}"))
5733        }
5734        _ => sanitize_project_label(fallback_path),
5735    }
5736}
5737
5738fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5739    let commit = commit_short.unwrap_or("").trim();
5740    if commit.is_empty() {
5741        project_label.to_string()
5742    } else {
5743        format!("{project_label}_{commit}")
5744    }
5745}
5746
5747// ── Async scan status + result handlers ──────────────────────────────────────
5748
5749#[derive(Serialize)]
5750#[serde(tag = "state", rename_all = "snake_case")]
5751enum AsyncRunStatusResponse {
5752    Running {
5753        elapsed_secs: u64,
5754        phase: String,
5755        files_done: u64,
5756        files_total: u64,
5757    },
5758    Complete {
5759        run_id: String,
5760    },
5761    Failed {
5762        message: String,
5763    },
5764    Cancelled,
5765}
5766
5767async fn async_run_status_handler(
5768    State(state): State<AppState>,
5769    AxumPath(wait_id): AxumPath<String>,
5770) -> Response {
5771    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5772    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5773        return error::bad_request("invalid wait_id");
5774    }
5775    let run_state = {
5776        let runs = state.async_runs.lock().await;
5777        runs.get(&wait_id).cloned()
5778    };
5779    match run_state {
5780        None => error::not_found("run not found"),
5781        Some(AsyncRunState::Running {
5782            started_at,
5783            phase,
5784            files_done,
5785            files_total,
5786            ..
5787        }) => {
5788            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5789            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5790                let mut runs = state.async_runs.lock().await;
5791                runs.insert(
5792                    wait_id,
5793                    AsyncRunState::Failed {
5794                        message: "Analysis timed out after 2 hours.".to_string(),
5795                    },
5796                );
5797                drop(runs);
5798                return Json(AsyncRunStatusResponse::Failed {
5799                    message: "Analysis timed out after 2 hours.".to_string(),
5800                })
5801                .into_response();
5802            }
5803            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5804            Json(AsyncRunStatusResponse::Running {
5805                elapsed_secs: started_at.elapsed().as_secs(),
5806                phase: phase_str,
5807                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5808                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5809            })
5810            .into_response()
5811        }
5812        Some(AsyncRunState::Complete { run_id }) => {
5813            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5814        }
5815        Some(AsyncRunState::Failed { message }) => {
5816            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5817        }
5818        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5819    }
5820}
5821
5822async fn cancel_run_handler(
5823    State(state): State<AppState>,
5824    AxumPath(wait_id): AxumPath<String>,
5825) -> Response {
5826    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5827        return error::bad_request("invalid wait_id");
5828    }
5829    let mut runs = state.async_runs.lock().await;
5830    let resp = match runs.get(&wait_id) {
5831        Some(AsyncRunState::Running { cancel_token, .. }) => {
5832            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5833            runs.insert(wait_id, AsyncRunState::Cancelled);
5834            StatusCode::OK.into_response()
5835        }
5836        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5837        _ => error::not_found("run not found"),
5838    };
5839    drop(runs);
5840    resp
5841}
5842
5843async fn async_run_result_handler(
5844    State(state): State<AppState>,
5845    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5846    AxumPath(run_id): AxumPath<String>,
5847) -> Response {
5848    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5849        return StatusCode::BAD_REQUEST.into_response();
5850    }
5851
5852    let artifacts = {
5853        let map = state.artifacts.lock().await;
5854        map.get(&run_id).cloned()
5855    };
5856    let artifacts = if let Some(a) = artifacts {
5857        a
5858    } else {
5859        let reg = state.registry.lock().await;
5860        if let Some(entry) = reg.find_by_run_id(&run_id) {
5861            recover_artifacts_from_registry(entry)
5862        } else {
5863            let html = ErrorTemplate {
5864                message: format!(
5865                    "Report not found. Run ID {} is not in the scan history.",
5866                    &run_id[..run_id.len().min(8)]
5867                ),
5868                last_report_url: Some("/view-reports".to_string()),
5869                last_report_label: Some("View Reports".to_string()),
5870                run_id: Some(run_id.clone()),
5871                error_code: Some(404),
5872                csp_nonce: csp_nonce.clone(),
5873                version: env!("CARGO_PKG_VERSION"),
5874            }
5875            .render()
5876            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5877            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5878        }
5879    };
5880
5881    let json_path = if let Some(p) = &artifacts.json_path {
5882        p.clone()
5883    } else {
5884        let html = ErrorTemplate {
5885            message: "JSON result was not saved for this run.".to_string(),
5886            last_report_url: Some("/view-reports".to_string()),
5887            last_report_label: Some("View Reports".to_string()),
5888            run_id: Some(run_id.clone()),
5889            error_code: Some(404),
5890            csp_nonce: csp_nonce.clone(),
5891            version: env!("CARGO_PKG_VERSION"),
5892        }
5893        .render()
5894        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5895        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5896    };
5897
5898    let Ok(run) = read_json(&json_path) else {
5899        let folder_hint = output_folder_hint(&json_path);
5900        let redirect_url = format!("/runs/result/{run_id}");
5901        return missing_scan_relocate_response(
5902            &format!(
5903                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5904                 deleted. Browse to the folder containing your scan output to reconnect it.",
5905                json_path.display()
5906            ),
5907            &run_id,
5908            &folder_hint,
5909            &redirect_url,
5910            state.server_mode,
5911            &csp_nonce,
5912        );
5913    };
5914
5915    let confluence_configured = {
5916        let store = state.confluence.lock().await;
5917        store.is_configured()
5918    };
5919
5920    render_result_page(
5921        &run,
5922        &artifacts,
5923        &run_id,
5924        &csp_nonce,
5925        confluence_configured,
5926        state.server_mode,
5927    )
5928}
5929
5930/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5931fn json_escape(s: &str) -> String {
5932    s.replace('\\', "\\\\").replace('"', "\\\"")
5933}
5934
5935/// Per-language line/symbol totals summed across every language in a run.
5936struct LangTotals {
5937    physical_lines: u64,
5938    code_lines: u64,
5939    comment_lines: u64,
5940    blank_lines: u64,
5941    mixed_lines: u64,
5942    functions: u64,
5943    classes: u64,
5944    variables: u64,
5945    imports: u64,
5946}
5947
5948fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5949    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5950        run.totals_by_language.iter().map(f).sum()
5951    };
5952    LangTotals {
5953        physical_lines: s(|r| r.total_physical_lines),
5954        code_lines: s(|r| r.code_lines),
5955        comment_lines: s(|r| r.comment_lines),
5956        blank_lines: s(|r| r.blank_lines),
5957        mixed_lines: s(|r| r.mixed_lines_separate),
5958        functions: s(|r| r.functions),
5959        classes: s(|r| r.classes),
5960        variables: s(|r| r.variables),
5961        imports: s(|r| r.imports),
5962    }
5963}
5964
5965/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5966struct DeltaFields {
5967    prev_fa_str: String,
5968    prev_fs_str: String,
5969    prev_pl_str: String,
5970    prev_cl_str: String,
5971    prev_cml_str: String,
5972    prev_bl_str: String,
5973    delta_fa_str: String,
5974    delta_fa_class: String,
5975    delta_fs_str: String,
5976    delta_fs_class: String,
5977    delta_pl_str: String,
5978    delta_pl_class: String,
5979    delta_cl_str: String,
5980    delta_cl_class: String,
5981    delta_cml_str: String,
5982    delta_cml_class: String,
5983    delta_bl_str: String,
5984    delta_bl_class: String,
5985    delta_lines_added: Option<i64>,
5986    delta_lines_removed: Option<i64>,
5987    delta_lines_net_str: String,
5988    delta_lines_net_class: String,
5989}
5990
5991// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5992// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5993// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5994// from those field names and obscure the 1:1 mapping.
5995#[allow(
5996    clippy::similar_names,
5997    reason = "locals mirror template-bound struct fields"
5998)]
5999fn compute_delta_fields(
6000    prev_entry: Option<&RegistryEntry>,
6001    totals: &LangTotals,
6002    files_analyzed: u64,
6003    files_skipped: u64,
6004    scan_delta: Option<&sloc_core::ScanComparison>,
6005) -> DeltaFields {
6006    let prev_sum = prev_entry.map(|e| &e.summary);
6007    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6008
6009    let (delta_fa_str, delta_fa_class) =
6010        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6011    let (delta_fs_str, delta_fs_class) =
6012        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6013    let (delta_pl_str, delta_pl_class) = summary_delta(
6014        totals.physical_lines,
6015        prev_sum.map(|s| s.total_physical_lines),
6016    );
6017    let (delta_cl_str, delta_cl_class) =
6018        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6019    let (delta_cml_str, delta_cml_class) =
6020        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6021    let (delta_bl_str, delta_bl_class) =
6022        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6023
6024    let delta_lines_added = scan_delta.map(sum_added_code_lines);
6025    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6026    let (delta_lines_net_str, delta_lines_net_class) =
6027        match (delta_lines_added, delta_lines_removed) {
6028            (Some(a), Some(r)) => {
6029                let net = a - r;
6030                (fmt_delta(net), delta_class(net).to_string())
6031            }
6032            _ => ("\u{2014}".to_string(), "na".to_string()),
6033        };
6034
6035    DeltaFields {
6036        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6037        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6038        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6039        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6040        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6041        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6042        delta_fa_str,
6043        delta_fa_class: delta_fa_class.to_string(),
6044        delta_fs_str,
6045        delta_fs_class: delta_fs_class.to_string(),
6046        delta_pl_str,
6047        delta_pl_class: delta_pl_class.to_string(),
6048        delta_cl_str,
6049        delta_cl_class: delta_cl_class.to_string(),
6050        delta_cml_str,
6051        delta_cml_class: delta_cml_class.to_string(),
6052        delta_bl_str,
6053        delta_bl_class: delta_bl_class.to_string(),
6054        delta_lines_added,
6055        delta_lines_removed,
6056        delta_lines_net_str,
6057        delta_lines_net_class,
6058    }
6059}
6060
6061/// Count of unchanged code lines in a scan comparison.
6062fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6063    scan_delta
6064        .file_deltas
6065        .iter()
6066        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6067        .map(|f| {
6068            #[allow(clippy::cast_sign_loss)]
6069            let n = f.current_code as u64;
6070            n
6071        })
6072        .sum()
6073}
6074
6075fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6076    run.git_remote_url
6077        .as_deref()
6078        .zip(run.git_commit_long.as_deref())
6079        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6080}
6081
6082fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6083    run.git_remote_url
6084        .as_deref()
6085        .zip(run.git_branch.as_deref())
6086        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6087}
6088
6089fn scan_performed_by(run: &AnalysisRun) -> String {
6090    run.environment.ci_name.clone().unwrap_or_else(|| {
6091        format!(
6092            "{} / {}",
6093            run.environment.initiator_username, run.environment.initiator_hostname
6094        )
6095    })
6096}
6097
6098/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
6099fn build_lang_chart_json(run: &AnalysisRun) -> String {
6100    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6101    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6102    let entries: Vec<String> = langs
6103        .into_iter()
6104        .take(12)
6105        .map(|l| {
6106            let name = json_escape(l.language.display_name());
6107            format!(
6108                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6109                name,
6110                l.code_lines,
6111                l.comment_lines,
6112                l.blank_lines,
6113                l.total_physical_lines,
6114                l.functions,
6115                l.classes,
6116                l.variables,
6117                l.imports,
6118                l.files,
6119            )
6120        })
6121        .collect();
6122    format!("[{}]", entries.join(","))
6123}
6124
6125/// Per-language files-vs-lines points as a JSON array for the scatter chart.
6126fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6127    let entries: Vec<String> = run
6128        .totals_by_language
6129        .iter()
6130        .map(|l| {
6131            let name = json_escape(l.language.display_name());
6132            format!(
6133                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6134                name, l.files, l.code_lines, l.total_physical_lines,
6135            )
6136        })
6137        .collect();
6138    format!("[{}]", entries.join(","))
6139}
6140
6141/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
6142fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6143    let entries: Vec<String> = run
6144        .totals_by_language
6145        .iter()
6146        .filter(|l| {
6147            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6148        })
6149        .map(|l| {
6150            let name = json_escape(l.language.display_name());
6151            format!(
6152                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6153                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6154            )
6155        })
6156        .collect();
6157    format!("[{}]", entries.join(","))
6158}
6159
6160/// Per-submodule line counts as a JSON array for the submodule chart.
6161fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6162    let entries: Vec<String> = run
6163        .submodule_summaries
6164        .iter()
6165        .map(|s| {
6166            let name = json_escape(&s.name);
6167            format!(
6168                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6169                name,
6170                s.code_lines,
6171                s.comment_lines,
6172                s.blank_lines,
6173                s.total_physical_lines,
6174                s.files_analyzed,
6175            )
6176        })
6177        .collect();
6178    format!("[{}]", entries.join(","))
6179}
6180
6181/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
6182#[allow(clippy::cast_precision_loss)]
6183fn cov_pct_str(hit: u64, found: u64) -> String {
6184    if found > 0 {
6185        format!("{:.1}", hit as f64 / found as f64 * 100.0)
6186    } else {
6187        String::new()
6188    }
6189}
6190
6191/// `hit / found` summary string, or empty when nothing was found.
6192fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6193    if found > 0 {
6194        format!("{hit} / {found}")
6195    } else {
6196        String::new()
6197    }
6198}
6199
6200const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6201    use sloc_core::CocomoMode;
6202    match mode {
6203        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6204        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6205        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6206    }
6207}
6208
6209const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6210    use sloc_core::CocomoMode;
6211    match mode {
6212        CocomoMode::Organic => "Organic",
6213        CocomoMode::SemiDetached => "Semi-detached",
6214        CocomoMode::Embedded => "Embedded",
6215    }
6216}
6217
6218const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6219    use sloc_core::CocomoMode;
6220    match mode {
6221        CocomoMode::Organic => {
6222            "Organic: A small team working on a well-understood project in a familiar \
6223             environment with minimal external constraints. Suited for internal tools, \
6224             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6225        }
6226        CocomoMode::SemiDetached => {
6227            "Semi-detached: A mixed team with varying experience tackling a project with \
6228             moderate novelty and some rigid constraints. Typical for compilers, transaction \
6229             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6230        }
6231        CocomoMode::Embedded => {
6232            "Embedded: Tight hardware, software, or operational constraints requiring \
6233             significant innovation and deep integration work. Typical for real-time control \
6234             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6235        }
6236    }
6237}
6238
6239/// COCOMO display strings recomputed for the scan-wizard-selected mode.
6240struct CocomoFields {
6241    has_cocomo: bool,
6242    effort_str: String,
6243    duration_str: String,
6244    staff_str: String,
6245    ksloc_str: String,
6246    mode_label: String,
6247    mode_tooltip: String,
6248}
6249
6250#[allow(clippy::cast_precision_loss)]
6251fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6252    use sloc_core::CocomoMode;
6253    let mode = match mode_str {
6254        "semi_detached" => CocomoMode::SemiDetached,
6255        "embedded" => CocomoMode::Embedded,
6256        _ => CocomoMode::Organic,
6257    };
6258    let (a, b, c, d) = cocomo_coefficients(mode);
6259    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6260    let effort = a * ksloc.powf(b);
6261    let duration = c * effort.powf(d);
6262    let staff = if duration > 0.0 {
6263        effort / duration
6264    } else {
6265        0.0
6266    };
6267    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6268    let mode_label = cocomo_mode_label(mode).to_string();
6269    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6270    if run.summary_totals.code_lines > 0 {
6271        CocomoFields {
6272            has_cocomo: true,
6273            effort_str: round2(effort),
6274            duration_str: round2(duration),
6275            staff_str: round2(staff),
6276            ksloc_str: round2(ksloc),
6277            mode_label,
6278            mode_tooltip,
6279        }
6280    } else {
6281        CocomoFields {
6282            has_cocomo: false,
6283            effort_str: String::new(),
6284            duration_str: String::new(),
6285            staff_str: String::new(),
6286            ksloc_str: String::new(),
6287            mode_label,
6288            mode_tooltip,
6289        }
6290    }
6291}
6292
6293#[allow(clippy::too_many_lines)]
6294#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
6295#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
6296fn render_result_page(
6297    run: &AnalysisRun,
6298    artifacts: &RunArtifacts,
6299    run_id: &str,
6300    csp_nonce: &str,
6301    confluence_configured: bool,
6302    server_mode: bool,
6303) -> Response {
6304    let ctx = &artifacts.result_context;
6305    let prev_entry = &ctx.prev_entry;
6306    let prev_scan_count = ctx.prev_scan_count;
6307    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
6308    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
6309    // field is never blank.
6310    let project_path_owned = if ctx.project_path.is_empty() {
6311        run.input_roots.join(", ")
6312    } else {
6313        ctx.project_path.clone()
6314    };
6315    let project_path = &project_path_owned;
6316
6317    let scan_delta = prev_entry.as_ref().and_then(|prev| {
6318        prev.json_path
6319            .as_ref()
6320            .and_then(|p| read_json(p).ok())
6321            .map(|prev_run| compute_delta(&prev_run, run))
6322    });
6323
6324    let files_analyzed = run.per_file_records.len() as u64;
6325    let files_skipped = run.skipped_file_records.len() as u64;
6326    let totals = sum_lang_totals(run);
6327
6328    let DeltaFields {
6329        prev_fa_str,
6330        prev_fs_str,
6331        prev_pl_str,
6332        prev_cl_str,
6333        prev_cml_str,
6334        prev_bl_str,
6335        delta_fa_str,
6336        delta_fa_class,
6337        delta_fs_str,
6338        delta_fs_class,
6339        delta_pl_str,
6340        delta_pl_class,
6341        delta_cl_str,
6342        delta_cl_class,
6343        delta_cml_str,
6344        delta_cml_class,
6345        delta_bl_str,
6346        delta_bl_class,
6347        delta_lines_added,
6348        delta_lines_removed,
6349        delta_lines_net_str,
6350        delta_lines_net_class,
6351    } = compute_delta_fields(
6352        prev_entry.as_ref(),
6353        &totals,
6354        files_analyzed,
6355        files_skipped,
6356        scan_delta.as_ref(),
6357    );
6358
6359    let run_dir = artifacts.output_dir.clone();
6360    let git_branch = run.git_branch.clone();
6361    let git_commit = run.git_commit_short.clone();
6362    let git_commit_long = run.git_commit_long.clone();
6363    let git_author = run.git_commit_author.clone();
6364    let git_commit_url = git_commit_url_for(run);
6365    let git_branch_url = git_branch_url_for(run);
6366    let scan_performed_by = scan_performed_by(run);
6367    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6368    let os_display = format!(
6369        "{} / {}",
6370        run.environment.operating_system, run.environment.architecture
6371    );
6372    let test_count = run.summary_totals.test_count;
6373
6374    // ── New metrics ──────────────────────────────────────────────────────────
6375    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6376    let lsloc = run.summary_totals.lsloc;
6377    let uloc = run.uloc;
6378    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6379    let duplicate_group_count = run.duplicate_groups.len();
6380
6381    // Re-compute COCOMO with the mode selected in the scan wizard.
6382    let ctx = &artifacts.result_context;
6383    let CocomoFields {
6384        has_cocomo,
6385        effort_str: cocomo_effort_str,
6386        duration_str: cocomo_duration_str,
6387        staff_str: cocomo_staff_str,
6388        ksloc_str: cocomo_ksloc_str,
6389        mode_label: cocomo_mode_label,
6390        mode_tooltip: cocomo_mode_tooltip,
6391    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6392    let complexity_alert = ctx.complexity_alert;
6393
6394    let template = ResultTemplate {
6395        version: env!("CARGO_PKG_VERSION"),
6396        report_title: run.effective_configuration.reporting.report_title.clone(),
6397        project_path: project_path.clone(),
6398        output_dir: display_path(&artifacts.output_dir),
6399        run_id: run_id.to_owned(),
6400        run_id_short: run_id
6401            .split('-')
6402            .next_back()
6403            .unwrap_or(run_id)
6404            .chars()
6405            .take(7)
6406            .collect(),
6407        files_analyzed,
6408        files_skipped,
6409        physical_lines: totals.physical_lines,
6410        code_lines: totals.code_lines,
6411        comment_lines: totals.comment_lines,
6412        blank_lines: totals.blank_lines,
6413        mixed_lines: totals.mixed_lines,
6414        functions: totals.functions,
6415        classes: totals.classes,
6416        variables: totals.variables,
6417        imports: totals.imports,
6418        html_url: artifacts
6419            .html_path
6420            .as_ref()
6421            .map(|_| format!("/runs/html/{run_id}")),
6422        pdf_url: artifacts
6423            .pdf_path
6424            .as_ref()
6425            .map(|_| format!("/runs/pdf/{run_id}")),
6426        json_url: artifacts
6427            .json_path
6428            .as_ref()
6429            .map(|_| format!("/runs/json/{run_id}")),
6430        html_download_url: artifacts
6431            .html_path
6432            .as_ref()
6433            .map(|_| format!("/runs/html/{run_id}?download=1")),
6434        pdf_download_url: artifacts
6435            .pdf_path
6436            .as_ref()
6437            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6438        json_download_url: artifacts
6439            .json_path
6440            .as_ref()
6441            .map(|_| format!("/runs/json/{run_id}?download=1")),
6442        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6443        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6444        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6445        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6446        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6447        prev_fa_str,
6448        prev_fs_str,
6449        prev_pl_str,
6450        prev_cl_str,
6451        prev_cml_str,
6452        prev_bl_str,
6453        delta_fa_str,
6454        delta_fa_class,
6455        delta_fs_str,
6456        delta_fs_class,
6457        delta_pl_str,
6458        delta_pl_class,
6459        delta_cl_str,
6460        delta_cl_class,
6461        delta_cml_str,
6462        delta_cml_class,
6463        delta_bl_str,
6464        delta_bl_class,
6465        delta_lines_added,
6466        delta_lines_removed,
6467        delta_lines_net_str,
6468        delta_lines_net_class,
6469        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6470        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6471        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6472        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6473        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6474        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6475        git_branch,
6476        git_branch_url,
6477        git_commit,
6478        git_commit_long,
6479        git_author,
6480        git_commit_url,
6481        scan_performed_by,
6482        scan_time_display,
6483        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6484        os_display,
6485        test_count,
6486        test_assertion_count: run.summary_totals.test_assertion_count,
6487        current_scan_number: prev_scan_count + 1,
6488        prev_scan_count,
6489        submodule_rows: run
6490            .submodule_summaries
6491            .iter()
6492            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6493            .collect(),
6494        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6495        scan_config_url: format!("/runs/scan-config/{run_id}"),
6496        lang_chart_json: build_lang_chart_json(run),
6497        scatter_chart_json: build_scatter_chart_json(run),
6498        semantic_chart_json: build_semantic_chart_json(run),
6499        submodule_chart_json: build_submodule_chart_json(run),
6500        has_submodule_data: !run.submodule_summaries.is_empty(),
6501        has_semantic_data: run
6502            .totals_by_language
6503            .iter()
6504            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6505        csp_nonce: csp_nonce.to_owned(),
6506        confluence_configured,
6507        server_mode,
6508        report_header_footer: run
6509            .effective_configuration
6510            .reporting
6511            .report_header_footer
6512            .clone(),
6513        is_offline: false,
6514        cyclomatic_complexity,
6515        lsloc,
6516        uloc,
6517        dryness_pct_str,
6518        duplicate_group_count,
6519        has_cocomo,
6520        cocomo_effort_str,
6521        cocomo_duration_str,
6522        cocomo_staff_str,
6523        cocomo_ksloc_str,
6524        cocomo_mode_label,
6525        cocomo_mode_tooltip,
6526        complexity_alert,
6527        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6528        cov_line_pct: cov_pct_str(
6529            run.summary_totals.coverage_lines_hit,
6530            run.summary_totals.coverage_lines_found,
6531        ),
6532        cov_fn_pct: cov_pct_str(
6533            run.summary_totals.coverage_functions_hit,
6534            run.summary_totals.coverage_functions_found,
6535        ),
6536        cov_branch_pct: cov_pct_str(
6537            run.summary_totals.coverage_branches_hit,
6538            run.summary_totals.coverage_branches_found,
6539        ),
6540        cov_lines_summary: cov_lines_summary_str(
6541            run.summary_totals.coverage_lines_hit,
6542            run.summary_totals.coverage_lines_found,
6543        ),
6544    };
6545
6546    Html(
6547        template
6548            .render()
6549            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6550    )
6551    .into_response()
6552}
6553
6554fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6555    let slug: String = report_title
6556        .chars()
6557        .map(|c| {
6558            if c.is_alphanumeric() || c == '-' {
6559                c.to_ascii_lowercase()
6560            } else {
6561                '_'
6562            }
6563        })
6564        .collect::<String>()
6565        .split('_')
6566        .filter(|s| !s.is_empty())
6567        .collect::<Vec<_>>()
6568        .join("_");
6569
6570    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6571
6572    if slug.is_empty() {
6573        format!("report_{short_id}.pdf")
6574    } else {
6575        format!("{slug}_{short_id}.pdf")
6576    }
6577}
6578
6579#[derive(Serialize)]
6580struct PdfStatusResponse {
6581    ready: bool,
6582}
6583
6584/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6585/// Clients poll this to update the button state without page reloads.
6586async fn pdf_status_handler(
6587    State(state): State<AppState>,
6588    AxumPath(run_id): AxumPath<String>,
6589) -> Response {
6590    let pdf_path = {
6591        let registry = state.artifacts.lock().await;
6592        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6593    };
6594    let pdf_path = if pdf_path.is_some() {
6595        pdf_path
6596    } else {
6597        let reg = state.registry.lock().await;
6598        reg.find_by_run_id(&run_id)
6599            .map(recover_artifacts_from_registry)
6600            .and_then(|a| a.pdf_path)
6601    };
6602    let ready = pdf_path.is_some_and(|p| p.exists());
6603    Json(PdfStatusResponse { ready }).into_response()
6604}
6605
6606/// GET /`api/runs/:run_id/bundle`
6607///
6608/// Streams a gzip-compressed tar archive containing every artifact in the run's
6609/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6610/// is built in memory so it never touches a temp file.
6611async fn download_bundle_handler(
6612    State(state): State<AppState>,
6613    AxumPath(run_id): AxumPath<String>,
6614) -> Response {
6615    // Resolve output directory from in-memory cache or persisted registry.
6616    let output_dir = {
6617        let cache = state.artifacts.lock().await;
6618        cache.get(&run_id).map(|a| a.output_dir.clone())
6619    };
6620    let output_dir = if let Some(d) = output_dir {
6621        d
6622    } else {
6623        let reg = state.registry.lock().await;
6624        match reg.find_by_run_id(&run_id) {
6625            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6626            None => {
6627                return (
6628                    StatusCode::NOT_FOUND,
6629                    Json(serde_json::json!({"error": "Run not found"})),
6630                )
6631                    .into_response();
6632            }
6633        }
6634    };
6635
6636    if !output_dir.exists() {
6637        return (
6638            StatusCode::NOT_FOUND,
6639            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6640        )
6641            .into_response();
6642    }
6643
6644    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6645    let run_id_clone = run_id.clone();
6646    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6647        use flate2::{Compression, write::GzEncoder};
6648        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6649        {
6650            let mut tar = tar::Builder::new(&mut enc);
6651            tar.follow_symlinks(false);
6652            // Append every regular file in the output directory, skipping
6653            // sub-directories (the output dir is always flat).
6654            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6655                for entry in entries.filter_map(Result::ok) {
6656                    let p = entry.path();
6657                    if p.is_file() {
6658                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6659                        let archive_path = format!("{run_id_clone}/{name}");
6660                        tar.append_path_with_name(&p, &archive_path)?;
6661                    }
6662                }
6663            }
6664            tar.finish()?;
6665        }
6666        Ok(enc.finish()?)
6667    })
6668    .await;
6669
6670    match archive_result {
6671        Ok(Ok(bytes)) => {
6672            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6673            axum::response::Response::builder()
6674                .status(StatusCode::OK)
6675                .header("Content-Type", "application/gzip")
6676                .header(
6677                    "Content-Disposition",
6678                    format!("attachment; filename=\"{filename}\""),
6679                )
6680                .header("Content-Length", bytes.len().to_string())
6681                .body(axum::body::Body::from(bytes))
6682                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6683        }
6684        Ok(Err(e)) => (
6685            StatusCode::INTERNAL_SERVER_ERROR,
6686            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6687        )
6688            .into_response(),
6689        Err(e) => (
6690            StatusCode::INTERNAL_SERVER_ERROR,
6691            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6692        )
6693            .into_response(),
6694    }
6695}
6696
6697/// DELETE /`api/runs/:run_id`
6698///
6699/// Removes all on-disk artifacts for the run and purges the run from the
6700/// in-memory cache and the persisted registry. Returns 204 on success.
6701async fn delete_run_handler(
6702    State(state): State<AppState>,
6703    AxumPath(run_id): AxumPath<String>,
6704) -> Response {
6705    // Resolve output directory.
6706    let output_dir = {
6707        let mut cache = state.artifacts.lock().await;
6708        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6709        cache.remove(&run_id);
6710        dir
6711    };
6712    let output_dir = if let Some(d) = output_dir {
6713        d
6714    } else {
6715        let reg = state.registry.lock().await;
6716        reg.find_by_run_id(&run_id)
6717            .map(|e| recover_artifacts_from_registry(e).output_dir)
6718            .unwrap_or_default()
6719    };
6720
6721    // Remove from persisted registry.
6722    {
6723        let mut reg = state.registry.lock().await;
6724        reg.entries.retain(|e| e.run_id != run_id);
6725        let _ = reg.save(&state.registry_path);
6726    }
6727
6728    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6729    // a prior delete may have already removed the directory.
6730    if output_dir.exists() {
6731        match tokio::fs::remove_dir_all(&output_dir).await {
6732            Ok(()) => {}
6733            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6734            Err(e) => {
6735                return (
6736                    StatusCode::INTERNAL_SERVER_ERROR,
6737                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6738                )
6739                    .into_response();
6740            }
6741        }
6742    }
6743
6744    StatusCode::NO_CONTENT.into_response()
6745}
6746
6747/// POST /api/runs/cleanup
6748///
6749/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6750/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6751async fn cleanup_runs_handler(
6752    State(state): State<AppState>,
6753    Json(body): Json<serde_json::Value>,
6754) -> Response {
6755    let days = body
6756        .get("older_than_days")
6757        .and_then(serde_json::Value::as_u64)
6758        .unwrap_or(30)
6759        .max(1);
6760
6761    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6762
6763    // Collect expired entries from the registry.
6764    let expired: Vec<(String, PathBuf)> = {
6765        let reg = state.registry.lock().await;
6766        reg.entries
6767            .iter()
6768            .filter(|e| e.timestamp_utc < cutoff)
6769            .map(|e| {
6770                let arts = recover_artifacts_from_registry(e);
6771                (e.run_id.clone(), arts.output_dir)
6772            })
6773            .collect()
6774    };
6775
6776    let mut deleted = 0usize;
6777    for (run_id, output_dir) in &expired {
6778        // Remove from in-memory cache.
6779        state.artifacts.lock().await.remove(run_id);
6780        // Delete on-disk artifacts (non-fatal if already gone).
6781        if output_dir.exists()
6782            && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
6783        {
6784            eprintln!(
6785                "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6786                output_dir.display()
6787            );
6788            continue;
6789        }
6790        deleted += 1;
6791    }
6792
6793    // Purge expired run IDs from the registry in one pass.
6794    let expired_ids: std::collections::HashSet<&str> =
6795        expired.iter().map(|(id, _)| id.as_str()).collect();
6796    {
6797        let mut reg = state.registry.lock().await;
6798        reg.entries
6799            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6800        let _ = reg.save(&state.registry_path);
6801    }
6802
6803    Json(serde_json::json!({ "deleted": deleted })).into_response()
6804}
6805
6806/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6807/// abort it when the policy is updated or disabled.
6808fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6809    tokio::spawn(async move {
6810        loop {
6811            let interval_secs = {
6812                let store = state.cleanup_policy.lock().await;
6813                match &store.policy {
6814                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6815                    _ => break,
6816                }
6817            };
6818            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6819            let n = run_auto_cleanup(&state).await;
6820            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6821        }
6822    })
6823}
6824
6825fn collect_runs_to_delete(
6826    reg: &ScanRegistry,
6827    max_age_days: Option<u32>,
6828    max_run_count: Option<u32>,
6829) -> std::collections::HashSet<String> {
6830    let mut to_delete = std::collections::HashSet::new();
6831    if let Some(days) = max_age_days {
6832        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6833        for e in &reg.entries {
6834            if e.timestamp_utc < cutoff {
6835                to_delete.insert(e.run_id.clone());
6836            }
6837        }
6838    }
6839    if let Some(max_count) = max_run_count {
6840        // entries are sorted newest-first; skip the ones we keep
6841        for e in reg.entries.iter().skip(max_count as usize) {
6842            to_delete.insert(e.run_id.clone());
6843        }
6844    }
6845    to_delete
6846}
6847
6848async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6849    let output_dir = {
6850        let mut cache = state.artifacts.lock().await;
6851        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6852        cache.remove(run_id);
6853        d
6854    };
6855    let output_dir = if let Some(d) = output_dir {
6856        d
6857    } else {
6858        let reg = state.registry.lock().await;
6859        reg.find_by_run_id(run_id)
6860            .map(|e| recover_artifacts_from_registry(e).output_dir)
6861            .unwrap_or_default()
6862    };
6863    if output_dir.exists() {
6864        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6865    }
6866}
6867
6868/// Core cleanup logic shared by the background task and the "Run Now" handler.
6869/// Applies both the age limit and the count limit, then updates `last_run_at`.
6870/// Returns the number of runs deleted.
6871async fn run_auto_cleanup(state: &AppState) -> u32 {
6872    let (max_age_days, max_run_count) = {
6873        let store = state.cleanup_policy.lock().await;
6874        match &store.policy {
6875            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6876            _ => return 0,
6877        }
6878    };
6879
6880    let to_delete = {
6881        let reg = state.registry.lock().await;
6882        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6883    };
6884
6885    for run_id in &to_delete {
6886        delete_run_artifacts(state, run_id).await;
6887    }
6888
6889    // Purge from registry.
6890    if !to_delete.is_empty() {
6891        let mut reg = state.registry.lock().await;
6892        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6893        let _ = reg.save(&state.registry_path);
6894    }
6895
6896    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6897    {
6898        let mut store = state.cleanup_policy.lock().await;
6899        store.last_run_at = Some(chrono::Utc::now());
6900        store.last_run_deleted = Some(deleted);
6901        let _ = store.save(&state.cleanup_policy_path);
6902    }
6903    deleted
6904}
6905
6906// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6907
6908/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6909async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6910    let store = state.cleanup_policy.lock().await;
6911    Json(serde_json::json!({
6912        "policy": store.policy,
6913        "last_run_at": store.last_run_at,
6914        "last_run_deleted": store.last_run_deleted,
6915    }))
6916    .into_response()
6917}
6918
6919/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6920async fn api_save_cleanup_policy(
6921    State(state): State<AppState>,
6922    Json(body): Json<CleanupPolicy>,
6923) -> Response {
6924    // Abort any running task so the new interval takes effect immediately.
6925    {
6926        let mut handle = state.cleanup_task_handle.lock().await;
6927        if let Some(h) = handle.take() {
6928            h.abort();
6929        }
6930    }
6931    {
6932        let mut store = state.cleanup_policy.lock().await;
6933        store.policy = Some(body.clone());
6934        if let Err(e) = store.save(&state.cleanup_policy_path) {
6935            return (
6936                StatusCode::INTERNAL_SERVER_ERROR,
6937                Json(serde_json::json!({"error": e.to_string()})),
6938            )
6939                .into_response();
6940        }
6941    }
6942    if body.enabled {
6943        let handle = spawn_cleanup_policy_task(state.clone());
6944        *state.cleanup_task_handle.lock().await = Some(handle);
6945    }
6946    StatusCode::NO_CONTENT.into_response()
6947}
6948
6949/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6950async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6951    let deleted = run_auto_cleanup(&state).await;
6952    Json(serde_json::json!({ "deleted": deleted })).into_response()
6953}
6954
6955/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6956async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6957    {
6958        let mut handle = state.cleanup_task_handle.lock().await;
6959        if let Some(h) = handle.take() {
6960            h.abort();
6961        }
6962    }
6963    {
6964        let mut store = state.cleanup_policy.lock().await;
6965        store.policy = None;
6966        let _ = store.save(&state.cleanup_policy_path);
6967    }
6968    StatusCode::NO_CONTENT.into_response()
6969}
6970
6971/// Serve the HTML artifact for a run — view or download.
6972/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6973/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6974/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6975/// Only called for browser views; downloads keep the self-contained inline version.
6976fn swap_inline_chart_js_for_static(html: String) -> String {
6977    let Some(head_end) = html.find("</head>") else {
6978        return html;
6979    };
6980    let Some(script_start) = html[..head_end].rfind("<script") else {
6981        return html;
6982    };
6983    let Some(close_offset) = html[script_start..].find("</script>") else {
6984        return html;
6985    };
6986    let block_end = script_start + close_offset + "</script>".len();
6987    format!(
6988        "{}<script src=\"/static/chart-report.js\"></script>{}",
6989        &html[..script_start],
6990        &html[block_end..]
6991    )
6992}
6993
6994/// current-request Content-Security-Policy nonce check.
6995fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6996    // Find the first nonce value that was baked in at render time.
6997    let Some(start) = html.find("nonce=\"") else {
6998        // Reports generated before nonce support was added have bare <style> and <script>
6999        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
7000        // the inline blocks — without it the browser blocks all CSS and JS.
7001        return html
7002            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7003            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7004    };
7005    let value_start = start + 7; // len(r#"nonce=""#) == 7
7006    let Some(end_offset) = html[value_start..].find('"') else {
7007        return html.to_owned();
7008    };
7009    let old_nonce = &html[value_start..value_start + end_offset];
7010    html.replace(
7011        &format!("nonce=\"{old_nonce}\""),
7012        &format!("nonce=\"{new_nonce}\""),
7013    )
7014}
7015
7016fn serve_html_artifact(
7017    path: &Path,
7018    wants_download: bool,
7019    csp_nonce: &str,
7020    run_id: &str,
7021    server_mode: bool,
7022) -> Response {
7023    match fs::read_to_string(path) {
7024        Ok(raw) => {
7025            // Patch the saved nonce so inline styles/scripts pass CSP.
7026            let content = patch_html_nonce(&raw, csp_nonce);
7027            if wants_download {
7028                // Keep the self-contained inline version for downloads (opened as file://).
7029                (
7030                    [
7031                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7032                        (
7033                            header::CONTENT_DISPOSITION,
7034                            "attachment; filename=report.html",
7035                        ),
7036                    ],
7037                    content,
7038                )
7039                    .into_response()
7040            } else {
7041                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
7042                // browser caches it after the first view; the HTML response also shrinks.
7043                Html(swap_inline_chart_js_for_static(content)).into_response()
7044            }
7045        }
7046        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7047            let filename = path.file_name().map_or_else(
7048                || "report.html".to_string(),
7049                |n| n.to_string_lossy().into_owned(),
7050            );
7051            let html = LocateFileTemplate {
7052                run_id: run_id.to_owned(),
7053                artifact_type: "html".to_string(),
7054                expected_filename: filename,
7055                server_mode,
7056                csp_nonce: csp_nonce.to_owned(),
7057                version: env!("CARGO_PKG_VERSION"),
7058            }
7059            .render()
7060            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7061            (StatusCode::NOT_FOUND, Html(html)).into_response()
7062        }
7063        Err(err) => {
7064            let filename = path.file_name().map_or_else(
7065                || "report.html".to_string(),
7066                |n| n.to_string_lossy().into_owned(),
7067            );
7068            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7069            let html = ErrorTemplate {
7070                message: msg,
7071                last_report_url: Some("/view-reports".to_string()),
7072                last_report_label: Some("View Reports".to_string()),
7073                run_id: None,
7074                error_code: Some(404),
7075                csp_nonce: csp_nonce.to_owned(),
7076                version: env!("CARGO_PKG_VERSION"),
7077            }
7078            .render()
7079            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7080            (StatusCode::NOT_FOUND, Html(html)).into_response()
7081        }
7082    }
7083}
7084
7085/// Serve the PDF artifact for a run — inline or download.
7086fn serve_pdf_artifact(
7087    path: &Path,
7088    report_title: &str,
7089    run_id: &str,
7090    wants_download: bool,
7091    csp_nonce: &str,
7092) -> Response {
7093    match fs::read(path) {
7094        Ok(bytes) => {
7095            let filename = build_pdf_filename(report_title, run_id);
7096            let disposition = if wants_download {
7097                format!("attachment; filename=\"{filename}\"")
7098            } else {
7099                format!("inline; filename=\"{filename}\"")
7100            };
7101            (
7102                [
7103                    (header::CONTENT_TYPE, "application/pdf".to_string()),
7104                    (header::CONTENT_DISPOSITION, disposition),
7105                ],
7106                bytes,
7107            )
7108                .into_response()
7109        }
7110        Err(err) => {
7111            let filename = path.file_name().map_or_else(
7112                || "report.pdf".to_string(),
7113                |n| n.to_string_lossy().into_owned(),
7114            );
7115            let msg = format!(
7116                "PDF report '{filename}' could not be read.\n\n\
7117                 Error: {err}\n\n\
7118                 If you moved or renamed the output folder, the stored path is now stale. \
7119                 Use 'Open PDF folder' from the results page to browse the output directory."
7120            );
7121            let html = ErrorTemplate {
7122                message: msg,
7123                last_report_url: Some("/view-reports".to_string()),
7124                last_report_label: Some("View Reports".to_string()),
7125                run_id: Some(run_id.to_owned()),
7126                error_code: Some(404),
7127                csp_nonce: csp_nonce.to_owned(),
7128                version: env!("CARGO_PKG_VERSION"),
7129            }
7130            .render()
7131            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7132            (StatusCode::NOT_FOUND, Html(html)).into_response()
7133        }
7134    }
7135}
7136
7137/// Serve the JSON artifact for a run — view or download.
7138fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7139    match fs::read(path) {
7140        Ok(bytes) => {
7141            if wants_download {
7142                (
7143                    [
7144                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7145                        (
7146                            header::CONTENT_DISPOSITION,
7147                            "attachment; filename=result.json",
7148                        ),
7149                    ],
7150                    bytes,
7151                )
7152                    .into_response()
7153            } else {
7154                (
7155                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7156                    bytes,
7157                )
7158                    .into_response()
7159            }
7160        }
7161        Err(err) => {
7162            let filename = path.file_name().map_or_else(
7163                || "result.json".to_string(),
7164                |n| n.to_string_lossy().into_owned(),
7165            );
7166            let msg = format!(
7167                "JSON result '{filename}' could not be read.\n\n\
7168                 Error: {err}\n\n\
7169                 If you moved or renamed the output folder, the stored path is now stale. \
7170                 Use 'Open JSON folder' from the results page to browse the output directory."
7171            );
7172            let html = ErrorTemplate {
7173                message: msg,
7174                last_report_url: Some("/view-reports".to_string()),
7175                last_report_label: Some("View Reports".to_string()),
7176                run_id: None,
7177                error_code: Some(404),
7178                csp_nonce: csp_nonce.to_owned(),
7179                version: env!("CARGO_PKG_VERSION"),
7180            }
7181            .render()
7182            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7183            (StatusCode::NOT_FOUND, Html(html)).into_response()
7184        }
7185    }
7186}
7187
7188/// Recover a `RunArtifacts` from the persisted registry for a run ID.
7189fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7190    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
7191    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
7192    let output_dir = entry
7193        .html_path
7194        .as_ref()
7195        .or(entry.json_path.as_ref())
7196        .or(entry.pdf_path.as_ref())
7197        .or(entry.csv_path.as_ref())
7198        .or(entry.xlsx_path.as_ref())
7199        .and_then(|p| {
7200            let parent = p.parent()?;
7201            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7202            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
7203            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7204                parent.parent().map(PathBuf::from)
7205            } else {
7206                Some(parent.to_path_buf())
7207            }
7208        })
7209        .unwrap_or_default();
7210    // Recover pdf_path: use the persisted one, or look for report.pdf
7211    // adjacent to html/json if only the old entries lack it.
7212    let pdf_path = entry.pdf_path.clone().or_else(|| {
7213        let candidate = output_dir.join("report.pdf");
7214        candidate.exists().then_some(candidate)
7215    });
7216    // csv_path / xlsx_path: persisted paths take precedence; fall back to
7217    // scanning the run directory for files matching the expected patterns so
7218    // that runs created before this feature still surface their artifacts.
7219    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7220        // Check excel/ subfolder (new layout) then root (old layout).
7221        for dir in &[output_dir.join("excel"), output_dir.clone()] {
7222            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7223                entries
7224                    .filter_map(std::result::Result::ok)
7225                    .find(|e| {
7226                        let n = e.file_name();
7227                        let n = n.to_string_lossy();
7228                        n.starts_with("report_") && n.ends_with(ext)
7229                    })
7230                    .map(|e| e.path())
7231            }) {
7232                return Some(p);
7233            }
7234        }
7235        None
7236    };
7237
7238    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7239    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7240    RunArtifacts {
7241        output_dir: output_dir.clone(),
7242        html_path: entry.html_path.clone(),
7243        pdf_path,
7244        json_path: entry.json_path.clone(),
7245        csv_path,
7246        xlsx_path,
7247        scan_config_path: find_scan_config_in_dir(&output_dir),
7248        report_title: entry.project_label.clone(),
7249        result_context: RunResultContext::default(),
7250    }
7251}
7252
7253#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
7254async fn resolve_artifact_set(
7255    state: &AppState,
7256    run_id: &str,
7257    csp_nonce: &str,
7258) -> Result<RunArtifacts, Response> {
7259    let cached = state.artifacts.lock().await.get(run_id).cloned();
7260    if let Some(a) = cached {
7261        return Ok(a);
7262    }
7263    let reg = state.registry.lock().await;
7264    if let Some(entry) = reg.find_by_run_id(run_id) {
7265        return Ok(recover_artifacts_from_registry(entry));
7266    }
7267    drop(reg);
7268    let short_id = &run_id[..run_id.len().min(8)];
7269    let hint = if matches!(
7270        run_id,
7271        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7272    ) {
7273        format!(
7274            " The URL format appears to be reversed \u{2014} \
7275             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7276             Use the View Reports page to navigate to your scan."
7277        )
7278    } else {
7279        " The report may have been deleted or the report directory moved. \
7280         Use View Reports to browse your scan history."
7281            .to_string()
7282    };
7283    let error_html = ErrorTemplate {
7284        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7285        last_report_url: Some("/view-reports".to_string()),
7286        last_report_label: Some("View Reports".to_string()),
7287        run_id: None,
7288        error_code: Some(404),
7289        csp_nonce: csp_nonce.to_owned(),
7290        version: env!("CARGO_PKG_VERSION"),
7291    }
7292    .render()
7293    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7294    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7295}
7296
7297/// Return the path to a run's PDF, queuing background generation when it is missing.
7298///
7299/// Returns `Ok(path)` when the PDF is known (it may still be generating).
7300/// Returns `Err(response)` when there is no JSON source to regenerate from.
7301async fn resolve_or_queue_pdf(
7302    state: &AppState,
7303    pdf_path: Option<PathBuf>,
7304    json_path: Option<PathBuf>,
7305    output_dir: PathBuf,
7306    run_id: &str,
7307    report_title: &str,
7308    csp_nonce: &str,
7309) -> Result<PathBuf, Response> {
7310    if let Some(p) = pdf_path {
7311        return Ok(p);
7312    }
7313    let Some(json_src) = json_path.filter(|p| p.exists()) else {
7314        let msg = "PDF report was not generated for this run. \
7315                   Re-run the analysis with PDF output enabled."
7316            .to_string();
7317        let html = ErrorTemplate {
7318            message: msg,
7319            last_report_url: Some(format!("/runs/html/{run_id}")),
7320            last_report_label: Some("View HTML Report".to_string()),
7321            run_id: Some(run_id.to_string()),
7322            error_code: Some(404),
7323            csp_nonce: csp_nonce.to_string(),
7324            version: env!("CARGO_PKG_VERSION"),
7325        }
7326        .render()
7327        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7328        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7329    };
7330    let pdf_filename = build_pdf_filename(report_title, run_id);
7331    let pdf_dest = output_dir.join(&pdf_filename);
7332    if !pdf_dest.exists() {
7333        // Record the pending path so concurrent requests show the spinner.
7334        {
7335            let mut map = state.artifacts.lock().await;
7336            if let Some(entry) = map.get_mut(run_id) {
7337                entry.pdf_path = Some(pdf_dest.clone());
7338            }
7339        }
7340        {
7341            let mut reg = state.registry.lock().await;
7342            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7343                e.pdf_path = Some(pdf_dest.clone());
7344            }
7345            let _ = reg.save(&state.registry_path);
7346        }
7347        spawn_native_pdf_background(
7348            json_src,
7349            pdf_dest.clone(),
7350            run_id.to_string(),
7351            state.artifacts.clone(),
7352        );
7353    }
7354    Ok(pdf_dest)
7355}
7356
7357/// Self-refreshing "please wait" page shown while the background PDF task is still running.
7358fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7359    let html = format!(
7360        "<!doctype html><html lang=\"en\"><head>\
7361                     <meta charset=utf-8>\
7362                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7363                     <meta http-equiv=\"refresh\" content=\"5\">\
7364                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
7365                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7366                     <style nonce=\"{csp_nonce}\">\
7367                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7368                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7369                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7370                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7371                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7372                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7373                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7374                     background:var(--bg);color:var(--text);}}\
7375                     .top-nav{{position:sticky;top:0;z-index:30;\
7376                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7377                     border-bottom:1px solid rgba(255,255,255,0.12);\
7378                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7379                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7380                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
7381                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7382                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7383                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7384                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7385                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7386                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7387                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7388                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7389                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7390                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7391                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7392                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7393                     justify-content:center;min-height:38px;border-radius:999px;\
7394                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7395                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7396                     .theme-toggle .icon-sun{{display:none;}}\
7397                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7398                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7399                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7400                     display:flex;align-items:center;justify-content:center;\
7401                     min-height:calc(100vh - 56px);}}\
7402                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7403                     .panel{{background:var(--surface);border:1px solid var(--line);\
7404                     border-radius:var(--radius);box-shadow:var(--shadow);\
7405                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7406                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
7407                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
7408                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7409                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7410                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7411                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7412                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7413                     min-height:42px;padding:0 20px;border-radius:14px;\
7414                     border:1px solid var(--line-strong);text-decoration:none;\
7415                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7416                     .back-link:hover{{background:var(--line);}}\
7417                     </style></head>\
7418                     <body>\
7419                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7420                       <a class=\"brand\" href=\"/\">\
7421                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7422                         <div class=\"brand-copy\">\
7423                           <div class=\"brand-title\">OxideSLOC</div>\
7424                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7425                         </div>\
7426                       </a>\
7427                       <div class=\"nav-right\">\
7428                         <a class=\"nav-pill\" href=\"/\">Home</a>\
7429                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7430                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7431                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7432                           <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>\
7433                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7434                           <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>\
7435                         </button>\
7436                       </div>\
7437                     </div></div>\
7438                     <div class=\"page\"><div class=\"panel\">\
7439                       <div class=\"spin-ring\"></div>\
7440                       <h1>Generating PDF\u{2026}</h1>\
7441                       <p>The PDF is being generated from the scan results.<br>\
7442                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7443                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7444                     </div></div>\
7445                     <script nonce=\"{csp_nonce}\">\
7446                     (function(){{\
7447                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7448                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7449                       var t=document.getElementById(\"theme-toggle\");\
7450                       if(t)t.addEventListener(\"click\",function(){{\
7451                         var d=b.classList.toggle(\"dark-theme\");\
7452                         localStorage.setItem(k,d?\"dark\":\"light\");\
7453                       }});\
7454                     }})();\
7455                     </script>\
7456                     </body></html>"
7457    );
7458    Html(html).into_response()
7459}
7460
7461/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7462fn render_error_artifact_html(
7463    message: String,
7464    last_report_url: Option<String>,
7465    last_report_label: Option<String>,
7466    run_id: Option<String>,
7467    error_code: Option<u16>,
7468    csp_nonce: &str,
7469) -> String {
7470    ErrorTemplate {
7471        message,
7472        last_report_url,
7473        last_report_label,
7474        run_id,
7475        error_code,
7476        csp_nonce: csp_nonce.to_owned(),
7477        version: env!("CARGO_PKG_VERSION"),
7478    }
7479    .render()
7480    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7481}
7482
7483/// Read a file and serve it as an attachment download.
7484fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7485    fs::read(path).map_or_else(
7486        |_| StatusCode::NOT_FOUND.into_response(),
7487        |bytes| {
7488            let filename = path.file_name().map_or_else(
7489                || fallback_filename.to_string(),
7490                |n| n.to_string_lossy().into_owned(),
7491            );
7492            (
7493                [
7494                    (header::CONTENT_TYPE, content_type.to_string()),
7495                    (
7496                        header::CONTENT_DISPOSITION,
7497                        format!("attachment; filename=\"{filename}\""),
7498                    ),
7499                ],
7500                bytes,
7501            )
7502                .into_response()
7503        },
7504    )
7505}
7506
7507fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7508    let Some(path) = csv_path else {
7509        let html = render_error_artifact_html(
7510            "CSV report was not generated for this run, or was not recorded in \
7511             the scan registry."
7512                .to_string(),
7513            Some(format!("/runs/html/{run_id}")),
7514            Some("View HTML Report".to_string()),
7515            Some(run_id.to_string()),
7516            Some(404),
7517            csp_nonce,
7518        );
7519        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7520    };
7521    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7522}
7523
7524fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7525    let Some(path) = xlsx_path else {
7526        let html = render_error_artifact_html(
7527            "Excel report was not generated for this run, or was not recorded in \
7528             the scan registry."
7529                .to_string(),
7530            Some(format!("/runs/html/{run_id}")),
7531            Some("View HTML Report".to_string()),
7532            Some(run_id.to_string()),
7533            Some(404),
7534            csp_nonce,
7535        );
7536        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7537    };
7538    serve_binary_download(
7539        &path,
7540        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7541        "report.xlsx",
7542    )
7543}
7544
7545fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7546    let path = artifact_set
7547        .scan_config_path
7548        .as_deref()
7549        .map(std::path::Path::to_path_buf)
7550        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7551        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7552    fs::read(&path).map_or_else(
7553        |_| StatusCode::NOT_FOUND.into_response(),
7554        |bytes| {
7555            (
7556                [
7557                    (
7558                        header::CONTENT_TYPE,
7559                        "application/json; charset=utf-8".to_string(),
7560                    ),
7561                    (
7562                        header::CONTENT_DISPOSITION,
7563                        "attachment; filename=\"scan-config.json\"".to_string(),
7564                    ),
7565                ],
7566                bytes,
7567            )
7568                .into_response()
7569        },
7570    )
7571}
7572
7573/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7574/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7575/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7576/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7577async fn serve_submodule_pdf_arm(
7578    artifact: &str,
7579    artifact_set: RunArtifacts,
7580    wants_download: bool,
7581    run_id: &str,
7582    csp_nonce: &str,
7583) -> Response {
7584    // "sub_benchmark_pdf" → base = "sub_benchmark"
7585    let base = artifact.trim_end_matches("_pdf");
7586    let sub_dir = artifact_set.output_dir.join("submodules");
7587    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7588
7589    if !pdf_path.exists() {
7590        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7591        let derived_safe = base.trim_start_matches("sub_");
7592        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7593            let parent_run = read_json(jp).ok()?;
7594            let sub = parent_run
7595                .submodule_summaries
7596                .iter()
7597                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7598                .clone();
7599            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7600            Some((parent_run, sub, parent_path))
7601        });
7602
7603        if let Some((parent_run, sub, parent_path)) = rebuilt {
7604            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7605            let pp = pdf_path.clone();
7606            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7607        }
7608    }
7609
7610    if !pdf_path.exists() {
7611        let html = render_error_artifact_html(
7612            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7613             enabled."
7614                .to_string(),
7615            Some("/view-reports".to_string()),
7616            Some("View Reports".to_string()),
7617            Some(run_id.to_string()),
7618            Some(404),
7619            csp_nonce,
7620        );
7621        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7622    }
7623
7624    serve_pdf_artifact(
7625        &pdf_path,
7626        &artifact_set.report_title,
7627        run_id,
7628        wants_download,
7629        csp_nonce,
7630    )
7631}
7632
7633fn serve_submodule_arm(
7634    artifact: &str,
7635    artifact_set: &RunArtifacts,
7636    wants_download: bool,
7637    csp_nonce: &str,
7638    run_id: &str,
7639    server_mode: bool,
7640) -> Response {
7641    if artifact.len() > 128
7642        || !artifact
7643            .chars()
7644            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7645    {
7646        return StatusCode::BAD_REQUEST.into_response();
7647    }
7648    let filename = format!("{artifact}.html");
7649    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7650    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7651    let path = if new_layout.exists() {
7652        new_layout
7653    } else {
7654        artifact_set.output_dir.join(&filename)
7655    };
7656    if !path.exists() {
7657        let html = render_error_artifact_html(
7658            format!(
7659                "Sub-report '{artifact}' was not found in the run directory.\n\
7660                 Re-run the analysis with 'Detect and separate git submodules' \
7661                 and HTML output enabled."
7662            ),
7663            Some("/view-reports".to_string()),
7664            Some("View Reports".to_string()),
7665            Some(run_id.to_string()),
7666            Some(404),
7667            csp_nonce,
7668        );
7669        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7670    }
7671    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7672}
7673
7674async fn serve_pdf_arm(
7675    state: &AppState,
7676    artifact_set: RunArtifacts,
7677    wants_download: bool,
7678    run_id: &str,
7679    csp_nonce: &str,
7680) -> Response {
7681    let report_title = artifact_set.report_title.clone();
7682    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7683    let stale_html_name = artifact_set
7684        .html_path
7685        .as_deref()
7686        .and_then(|p| p.file_name())
7687        .map(|n| n.to_string_lossy().into_owned());
7688    let path = match resolve_or_queue_pdf(
7689        state,
7690        artifact_set.pdf_path,
7691        artifact_set.json_path.clone(),
7692        artifact_set.output_dir.clone(),
7693        run_id,
7694        &report_title,
7695        csp_nonce,
7696    )
7697    .await
7698    {
7699        Ok(p) => p,
7700        Err(r) => return r,
7701    };
7702    if !path.exists() {
7703        // Distinguish a stale registry path (folder moved) from an in-progress
7704        // background generation. Only show the locate page when the PDF was
7705        // already recorded in the registry but the file is now missing.
7706        if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
7707            let html = LocateFileTemplate {
7708                run_id: run_id.to_string(),
7709                artifact_type: "pdf".to_string(),
7710                expected_filename,
7711                server_mode: state.server_mode,
7712                csp_nonce: csp_nonce.to_string(),
7713                version: env!("CARGO_PKG_VERSION"),
7714            }
7715            .render()
7716            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7717            return (StatusCode::NOT_FOUND, Html(html)).into_response();
7718        }
7719        return pdf_generating_response(run_id, csp_nonce);
7720    }
7721    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7722}
7723
7724async fn artifact_handler(
7725    State(state): State<AppState>,
7726    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7727    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7728    Query(query): Query<ArtifactQuery>,
7729) -> Response {
7730    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7731        Ok(a) => a,
7732        Err(r) => return r,
7733    };
7734
7735    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7736
7737    match artifact.as_str() {
7738        "html" => {
7739            let Some(path) = artifact_set.html_path else {
7740                return StatusCode::NOT_FOUND.into_response();
7741            };
7742            serve_html_artifact(
7743                &path,
7744                wants_download,
7745                &csp_nonce,
7746                &run_id,
7747                state.server_mode,
7748            )
7749        }
7750        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7751        "json" => {
7752            let Some(path) = artifact_set.json_path else {
7753                let html = render_error_artifact_html(
7754                    "JSON result was not generated for this run, or was not recorded in \
7755                     the scan registry. Re-run the analysis with JSON output enabled."
7756                        .to_string(),
7757                    Some("/view-reports".to_string()),
7758                    Some("View Reports".to_string()),
7759                    Some(run_id.clone()),
7760                    Some(404),
7761                    &csp_nonce,
7762                );
7763                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7764            };
7765            serve_json_artifact(&path, wants_download, &csp_nonce)
7766        }
7767        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7768        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7769        "scan-config" => serve_scan_config_arm(&artifact_set),
7770        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7771            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7772                .await
7773        }
7774        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7775            &artifact,
7776            &artifact_set,
7777            wants_download,
7778            &csp_nonce,
7779            &run_id,
7780            state.server_mode,
7781        ),
7782        _ => StatusCode::NOT_FOUND.into_response(),
7783    }
7784}
7785
7786// ── History ───────────────────────────────────────────────────────────────────
7787
7788struct SubmoduleLinkRow {
7789    name: String,
7790    url: String,
7791}
7792
7793struct HistoryEntryRow {
7794    run_id: String,
7795    run_id_short: String,
7796    timestamp: String,
7797    timestamp_utc_ms: i64,
7798    project_label: String,
7799    project_path: String,
7800    files_analyzed: u64,
7801    files_skipped: u64,
7802    code_lines: u64,
7803    comment_lines: u64,
7804    blank_lines: u64,
7805    total_physical_lines: u64,
7806    functions: u64,
7807    classes: u64,
7808    variables: u64,
7809    imports: u64,
7810    test_count: u64,
7811    git_branch: String,
7812    git_commit: String,
7813    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7814    git_commit_long: String,
7815    has_html: bool,
7816    has_json: bool,
7817    has_pdf: bool,
7818    submodule_links: Vec<SubmoduleLinkRow>,
7819    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7820    submodule_names_csv: String,
7821}
7822
7823/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7824fn nth_weekday_of_month(
7825    year: i32,
7826    month: u32,
7827    weekday: chrono::Weekday,
7828    n: u32,
7829) -> chrono::NaiveDate {
7830    use chrono::Datelike;
7831    let mut count = 0u32;
7832    let mut day = 1u32;
7833    loop {
7834        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7835        if d.weekday() == weekday {
7836            count += 1;
7837            if count == n {
7838                return d;
7839            }
7840        }
7841        day += 1;
7842    }
7843}
7844
7845/// Returns true if `dt` falls within US Pacific Daylight Time.
7846/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7847/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7848fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7849    use chrono::{Datelike, TimeZone};
7850    let year = dt.year();
7851    let dst_start = chrono::Utc.from_utc_datetime(
7852        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7853            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7854    );
7855    let dst_end = chrono::Utc.from_utc_datetime(
7856        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7857            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7858    );
7859    dt >= dst_start && dt < dst_end
7860}
7861
7862fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7863    if is_pacific_dst(dt) {
7864        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7865            .format("%Y-%m-%d %H:%M PDT")
7866            .to_string()
7867    } else {
7868        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7869            .format("%Y-%m-%d %H:%M PST")
7870            .to_string()
7871    }
7872}
7873
7874/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7875fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7876    let (offset, tz) = if is_pacific_dst(dt) {
7877        (
7878            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7879            "PDT",
7880        )
7881    } else {
7882        (
7883            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7884            "PST",
7885        )
7886    };
7887    format!(
7888        "{} {tz}",
7889        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7890    )
7891}
7892
7893fn fmt_git_date(iso: &str) -> Option<String> {
7894    chrono::DateTime::parse_from_rfc3339(iso)
7895        .ok()
7896        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7897}
7898
7899/// Recover the full-length commit SHA for a registry entry whose stored record
7900/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7901///
7902/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7903/// is serialized after the per-file records, near the end of the file. We read a
7904/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7905/// known short SHA — this disambiguates the super-repo commit from any submodule
7906/// commits that also appear. Returns `None` if the file is unreadable or no match.
7907fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7908    use std::io::{Read, Seek, SeekFrom};
7909    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7910    if short.is_empty() {
7911        return None;
7912    }
7913    let len = std::fs::metadata(path).ok()?.len();
7914    let start = len.saturating_sub(TAIL);
7915    let mut file = std::fs::File::open(path).ok()?;
7916    file.seek(SeekFrom::Start(start)).ok()?;
7917    let mut buf = Vec::new();
7918    file.read_to_end(&mut buf).ok()?;
7919    let text = String::from_utf8_lossy(&buf);
7920    let short_lower = short.to_ascii_lowercase();
7921    let key = "\"git_commit_long\"";
7922    let mut found: Option<String> = None;
7923    let mut cursor = 0usize;
7924    while let Some(idx) = text[cursor..].find(key) {
7925        let after_key = cursor + idx + key.len();
7926        cursor = after_key;
7927        let rest = &text[after_key..];
7928        let Some(colon) = rest.find(':') else { break };
7929        let value_region = rest[colon + 1..].trim_start();
7930        // Skip `null` (or any non-string) values without consuming the next field.
7931        if let Some(open) = value_region.strip_prefix('"')
7932            && let Some(close) = open.find('"')
7933        {
7934            let val = &open[..close];
7935            if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7936                found = Some(val.to_string());
7937            }
7938        }
7939    }
7940    found
7941}
7942
7943fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7944    reg.entries
7945        .iter()
7946        .map(|e| {
7947            let submodule_links = {
7948                let mut links: Vec<SubmoduleLinkRow> = vec![];
7949                let sub_dir = e
7950                    .html_path
7951                    .as_ref()
7952                    .and_then(|p| p.parent())
7953                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7954                if let Some(dir) = sub_dir
7955                    && let Ok(rd) = std::fs::read_dir(dir)
7956                {
7957                    for entry_res in rd.flatten() {
7958                        let fname = entry_res.file_name();
7959                        let fname_str = fname.to_string_lossy();
7960                        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7961                            let stem = &fname_str[..fname_str.len() - 5];
7962                            let display = stem[4..].replace('-', " ");
7963                            links.push(SubmoduleLinkRow {
7964                                name: display,
7965                                url: format!("/runs/{stem}/{}", e.run_id),
7966                            });
7967                        }
7968                    }
7969                }
7970                links.sort_by(|a, b| a.name.cmp(&b.name));
7971                links
7972            };
7973            let submodule_names_csv = submodule_links
7974                .iter()
7975                .map(|l| l.name.as_str())
7976                .collect::<Vec<_>>()
7977                .join(",");
7978            HistoryEntryRow {
7979                run_id: e.run_id.clone(),
7980                run_id_short: e
7981                    .run_id
7982                    .split('-')
7983                    .next_back()
7984                    .unwrap_or(&e.run_id)
7985                    .chars()
7986                    .take(7)
7987                    .collect(),
7988                timestamp: fmt_la_time(e.timestamp_utc),
7989                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7990                project_label: e.project_label.clone(),
7991                project_path: e
7992                    .input_roots
7993                    .first()
7994                    .map(|s| sanitize_path_str(s))
7995                    .unwrap_or_default(),
7996                files_analyzed: e.summary.files_analyzed,
7997                files_skipped: e.summary.files_skipped,
7998                code_lines: e.summary.code_lines,
7999                comment_lines: e.summary.comment_lines,
8000                blank_lines: e.summary.blank_lines,
8001                total_physical_lines: e.summary.total_physical_lines,
8002                functions: e.summary.functions,
8003                classes: e.summary.classes,
8004                variables: e.summary.variables,
8005                imports: e.summary.imports,
8006                test_count: e.summary.test_count,
8007                git_branch: e.git_branch.clone().unwrap_or_default(),
8008                git_commit: e.git_commit.clone().unwrap_or_default(),
8009                git_commit_long: {
8010                    let short = e.git_commit.clone().unwrap_or_default();
8011                    e.git_commit_long
8012                        .clone()
8013                        .filter(|s| !s.is_empty())
8014                        .or_else(|| {
8015                            e.json_path
8016                                .as_ref()
8017                                .and_then(|p| extract_long_commit_from_json(p, &short))
8018                        })
8019                        .unwrap_or(short)
8020                },
8021                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8022                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8023                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8024                submodule_links,
8025                submodule_names_csv,
8026            }
8027        })
8028        .collect()
8029}
8030
8031#[derive(Deserialize, Default)]
8032struct HistoryQuery {
8033    linked: Option<String>,
8034    error: Option<String>,
8035}
8036
8037async fn history_handler(
8038    State(state): State<AppState>,
8039    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8040    Query(query): Query<HistoryQuery>,
8041) -> impl IntoResponse {
8042    // Auto-scan all watched directories before rendering so the list stays fresh.
8043    auto_scan_watched_dirs(&state).await;
8044    let watched_dirs: Vec<String> = {
8045        let wd = state.watched_dirs.lock().await;
8046        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8047    };
8048    let mut entries = {
8049        let reg = state.registry.lock().await;
8050        make_history_rows(&reg)
8051    };
8052    entries.retain(|e| e.has_html);
8053    let total_scans = entries.len();
8054    let linked_count = query
8055        .linked
8056        .as_deref()
8057        .and_then(|s| s.parse::<usize>().ok())
8058        .unwrap_or(0);
8059    let browse_error = query.error.filter(|s| !s.is_empty());
8060    let template = HistoryTemplate {
8061        version: env!("CARGO_PKG_VERSION"),
8062        entries,
8063        total_scans,
8064        linked_count,
8065        browse_error,
8066        watched_dirs,
8067        csp_nonce,
8068        server_mode: state.server_mode,
8069    };
8070    Html(
8071        template
8072            .render()
8073            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8074    )
8075    .into_response()
8076}
8077
8078async fn compare_select_handler(
8079    State(state): State<AppState>,
8080    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8081) -> impl IntoResponse {
8082    auto_scan_watched_dirs(&state).await;
8083    let watched_dirs: Vec<String> = {
8084        let wd = state.watched_dirs.lock().await;
8085        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8086    };
8087    let mut entries = {
8088        let reg = state.registry.lock().await;
8089        make_history_rows(&reg)
8090    };
8091    entries.retain(|e| e.has_json);
8092    let total_scans = entries.len();
8093    let template = CompareSelectTemplate {
8094        version: env!("CARGO_PKG_VERSION"),
8095        entries,
8096        total_scans,
8097        watched_dirs,
8098        csp_nonce,
8099        server_mode: state.server_mode,
8100    };
8101    Html(
8102        template
8103            .render()
8104            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8105    )
8106    .into_response()
8107}
8108
8109// ── Compare ───────────────────────────────────────────────────────────────────
8110
8111#[derive(Deserialize, Default)]
8112struct CompareQuery {
8113    a: Option<String>,
8114    b: Option<String>,
8115    /// Optional submodule name to scope the comparison to one submodule.
8116    sub: Option<String>,
8117    /// "super" to exclude all submodule files and show only the super-repo.
8118    scope: Option<String>,
8119}
8120
8121struct CompareFileDeltaRow {
8122    relative_path: String,
8123    language: String,
8124    status: String,
8125    baseline_code: i64,
8126    current_code: i64,
8127    baseline_code_display: String,
8128    current_code_display: String,
8129    code_delta_str: String,
8130    code_delta_class: String,
8131    comment_delta_str: String,
8132    comment_delta_class: String,
8133    total_delta_str: String,
8134    total_delta_class: String,
8135}
8136
8137/// Recompute `summary_totals` from the current `per_file_records` slice.
8138/// Used when `per_file_records` has been narrowed to a submodule subset.
8139fn recompute_summary_from_records(run: &mut AnalysisRun) {
8140    let mut totals = SummaryTotals::default();
8141    for r in &run.per_file_records {
8142        if r.language.is_some() {
8143            totals.files_analyzed += 1;
8144        }
8145        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8146        totals.code_lines += r.effective_counts.code_lines;
8147        totals.comment_lines += r.effective_counts.comment_lines;
8148        totals.blank_lines += r.effective_counts.blank_lines;
8149        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8150        totals.functions += r.raw_line_categories.functions;
8151        totals.classes += r.raw_line_categories.classes;
8152        totals.variables += r.raw_line_categories.variables;
8153        totals.imports += r.raw_line_categories.imports;
8154        totals.test_count += r.raw_line_categories.test_count;
8155        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8156        totals.test_suite_count += r.raw_line_categories.test_suite_count;
8157        if let Some(cov) = &r.coverage {
8158            totals.coverage_lines_found += u64::from(cov.lines_found);
8159            totals.coverage_lines_hit += u64::from(cov.lines_hit);
8160            totals.coverage_functions_found += u64::from(cov.functions_found);
8161            totals.coverage_functions_hit += u64::from(cov.functions_hit);
8162            totals.coverage_branches_found += u64::from(cov.branches_found);
8163            totals.coverage_branches_hit += u64::from(cov.branches_hit);
8164        }
8165    }
8166    totals.files_considered = totals.files_analyzed;
8167    run.summary_totals = totals;
8168}
8169
8170fn fmt_delta(n: i64) -> String {
8171    if n > 0 {
8172        format!("+{n}")
8173    } else {
8174        format!("{n}")
8175    }
8176}
8177
8178fn delta_class(n: i64) -> &'static str {
8179    use std::cmp::Ordering;
8180    match n.cmp(&0) {
8181        Ordering::Greater => "pos",
8182        Ordering::Less => "neg",
8183        Ordering::Equal => "zero",
8184    }
8185}
8186
8187// ratio/percentage display, precision loss acceptable
8188#[allow(clippy::cast_precision_loss)]
8189fn fmt_pct(delta: i64, baseline: u64) -> String {
8190    if baseline == 0 {
8191        return "—".to_string();
8192    }
8193    #[allow(clippy::cast_precision_loss)]
8194    let pct = (delta as f64 / baseline as f64) * 100.0;
8195    if pct > 0.049 {
8196        format!("+{pct:.1}%")
8197    } else if pct < -0.049 {
8198        format!("{pct:.1}%")
8199    } else {
8200        "±0%".to_string()
8201    }
8202}
8203
8204/// Returns (`display_string`, `css_class`) for a numeric change column cell.
8205fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8206    prev.map_or_else(
8207        || ("—".to_string(), "na"),
8208        |p| {
8209            #[allow(clippy::cast_possible_wrap)]
8210            let d = curr as i64 - p as i64;
8211            (fmt_delta(d), delta_class(d))
8212        },
8213    )
8214}
8215
8216#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
8217fn load_scan_for_compare(
8218    json_path: &std::path::Path,
8219    scan_label: &str,
8220    run_id: &str,
8221    server_mode: bool,
8222    compare_url: &str,
8223    csp_nonce: &str,
8224) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8225    match read_json(json_path) {
8226        Ok(r) => Ok(r),
8227        Err(e) => {
8228            if server_mode {
8229                let html = ErrorTemplate {
8230                    message: format!(
8231                        "Could not load {scan_label} scan data. The scan output folder may have \
8232                         been moved, renamed, or deleted. Re-running the analysis will create \
8233                         fresh comparison data."
8234                    ),
8235                    last_report_url: Some("/compare-scans".to_string()),
8236                    last_report_label: Some("Compare Scans".to_string()),
8237                    run_id: Some(run_id.to_owned()),
8238                    error_code: Some(404),
8239                    csp_nonce: csp_nonce.to_owned(),
8240                    version: env!("CARGO_PKG_VERSION"),
8241                }
8242                .render()
8243                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8244                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8245            }
8246            let msg = format!(
8247                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8248                json_path.display()
8249            );
8250            let folder_hint = output_folder_hint(json_path);
8251            Err(missing_scan_relocate_response(
8252                &msg,
8253                run_id,
8254                &folder_hint,
8255                compare_url,
8256                false,
8257                csp_nonce,
8258            ))
8259        }
8260    }
8261}
8262
8263struct ChurnStats {
8264    new_scope: bool,
8265    scope_flag: bool,
8266    churn_rate_str: String,
8267    churn_rate_class: String,
8268}
8269
8270fn compute_churn_stats(
8271    baseline_code: u64,
8272    current_code: u64,
8273    lines_added: i64,
8274    lines_removed: i64,
8275) -> ChurnStats {
8276    let new_scope = baseline_code == 0 && current_code > 0;
8277    #[allow(clippy::cast_precision_loss)]
8278    let churn_pct = if baseline_code > 0 {
8279        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8280    } else {
8281        0.0
8282    };
8283    #[allow(clippy::cast_precision_loss)]
8284    let scope_flag =
8285        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8286    let churn_rate_str = if new_scope {
8287        "New".to_string()
8288    } else if baseline_code > 0 {
8289        format!("{churn_pct:.1}%")
8290    } else {
8291        "—".to_string()
8292    };
8293    let churn_rate_class = if new_scope || churn_pct > 20.0 {
8294        "high".to_string()
8295    } else if churn_pct > 5.0 {
8296        "med".to_string()
8297    } else {
8298        "low".to_string()
8299    };
8300    ChurnStats {
8301        new_scope,
8302        scope_flag,
8303        churn_rate_str,
8304        churn_rate_class,
8305    }
8306}
8307
8308/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
8309/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
8310/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
8311fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8312    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8313    if !has_data {
8314        return String::new();
8315    }
8316    let base_str = s
8317        .baseline_coverage_line_pct
8318        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8319    let curr_str = s
8320        .current_coverage_line_pct
8321        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8322    let (delta_str, cls) = match s.coverage_line_pct_delta {
8323        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8324        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8325        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8326        None => ("\u{2014}".into(), "zero"),
8327    };
8328    format!(
8329        r#"<div class="delta-card">
8330          <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo.<br>Positive delta = more lines instrumented and hit.<br>Only shown when at least one scan has coverage data.</div>
8331          <div class="delta-card-label">Line coverage</div>
8332          <div class="delta-card-from">Before: {base_str}</div>
8333          <div class="delta-card-to">{curr_str}</div>
8334          <span class="delta-card-change {cls}">{delta_str}</span>
8335        </div>"#
8336    )
8337}
8338
8339/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
8340#[allow(clippy::ref_option)]
8341fn narrow_run_pair_by_scope(
8342    mut baseline: AnalysisRun,
8343    mut current: AnalysisRun,
8344    active_sub: &Option<String>,
8345    super_scope: bool,
8346) -> (AnalysisRun, AnalysisRun) {
8347    if let Some(sub_name) = active_sub {
8348        baseline
8349            .per_file_records
8350            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8351        current
8352            .per_file_records
8353            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8354        recompute_summary_from_records(&mut baseline);
8355        recompute_summary_from_records(&mut current);
8356    } else if super_scope {
8357        baseline.per_file_records.retain(|f| f.submodule.is_none());
8358        current.per_file_records.retain(|f| f.submodule.is_none());
8359        recompute_summary_from_records(&mut baseline);
8360        recompute_summary_from_records(&mut current);
8361    }
8362    (baseline, current)
8363}
8364
8365/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
8366#[allow(clippy::ref_option)]
8367fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8368    if let Some(sub_name) = active_sub {
8369        for run in runs.iter_mut() {
8370            run.per_file_records
8371                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8372            recompute_summary_from_records(run);
8373        }
8374    } else if super_scope {
8375        for run in runs.iter_mut() {
8376            run.per_file_records.retain(|f| f.submodule.is_none());
8377            recompute_summary_from_records(run);
8378        }
8379    }
8380}
8381
8382#[allow(clippy::too_many_lines)]
8383async fn compare_handler(
8384    State(state): State<AppState>,
8385    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8386    Query(query): Query<CompareQuery>,
8387) -> impl IntoResponse {
8388    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
8389    // redirect to the history page where the user can select two runs.
8390    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8391        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8392        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8393    };
8394
8395    let (maybe_a, maybe_b) = {
8396        let reg = state.registry.lock().await;
8397        (
8398            reg.find_by_run_id(&run_id_a).cloned(),
8399            reg.find_by_run_id(&run_id_b).cloned(),
8400        )
8401    };
8402
8403    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8404        let html = ErrorTemplate {
8405            message: "One or both run IDs were not found in scan history. \
8406                      The runs may have been deleted or the registry may have been reset."
8407                .to_string(),
8408            last_report_url: Some("/compare-scans".to_string()),
8409            last_report_label: Some("Compare Scans".to_string()),
8410            run_id: None,
8411            error_code: None,
8412            csp_nonce: csp_nonce.clone(),
8413            version: env!("CARGO_PKG_VERSION"),
8414        }
8415        .render()
8416        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8417        return Html(html).into_response();
8418    };
8419
8420    // Ensure older scan is always the baseline.
8421    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8422        (entry_a, entry_b)
8423    } else {
8424        (entry_b, entry_a)
8425    };
8426
8427    // If query params were in the wrong order, redirect to canonical URL so the
8428    // browser always shows the same URL for the same two scans regardless of how
8429    // the user arrived here (Full diff button vs. Compare Scans selection).
8430    if baseline_entry.run_id != run_id_a {
8431        let canonical = format!(
8432            "/compare?a={}&b={}",
8433            baseline_entry.run_id, current_entry.run_id
8434        );
8435        return axum::response::Redirect::to(&canonical).into_response();
8436    }
8437
8438    let (Some(base_json), Some(curr_json)) = (
8439        baseline_entry.json_path.as_ref(),
8440        current_entry.json_path.as_ref(),
8441    ) else {
8442        let html = ErrorTemplate {
8443            message: "Full comparison requires JSON scan data, which was not saved for one or \
8444                      both of these runs. JSON is now always saved for new scans — re-run the \
8445                      affected projects to enable comparisons."
8446                .to_string(),
8447            last_report_url: Some("/compare-scans".to_string()),
8448            last_report_label: Some("Compare Scans".to_string()),
8449            run_id: None,
8450            error_code: None,
8451            csp_nonce: csp_nonce.clone(),
8452            version: env!("CARGO_PKG_VERSION"),
8453        }
8454        .render()
8455        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8456        return Html(html).into_response();
8457    };
8458
8459    let compare_url = format!(
8460        "/compare?a={}&b={}",
8461        baseline_entry.run_id, current_entry.run_id
8462    );
8463
8464    let baseline_run = match load_scan_for_compare(
8465        base_json,
8466        "baseline",
8467        &baseline_entry.run_id,
8468        state.server_mode,
8469        &compare_url,
8470        &csp_nonce,
8471    ) {
8472        Ok(r) => r,
8473        Err(resp) => return resp,
8474    };
8475    let current_run = match load_scan_for_compare(
8476        curr_json,
8477        "current",
8478        &current_entry.run_id,
8479        state.server_mode,
8480        &compare_url,
8481        &csp_nonce,
8482    ) {
8483        Ok(r) => r,
8484        Err(resp) => return resp,
8485    };
8486
8487    let active_submodule = query.sub.clone();
8488    let super_scope_active = query.scope.as_deref() == Some("super");
8489
8490    let submodule_options = baseline_run
8491        .submodule_summaries
8492        .iter()
8493        .chain(current_run.submodule_summaries.iter())
8494        .map(|s| s.name.clone())
8495        .collect::<std::collections::BTreeSet<_>>()
8496        .into_iter()
8497        .collect::<Vec<_>>();
8498    let has_any_submodule_data = !submodule_options.is_empty();
8499
8500    // Narrow per_file_records when a scope is active, then recompute totals.
8501    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8502        baseline_run,
8503        current_run,
8504        &active_submodule,
8505        super_scope_active,
8506    );
8507
8508    let comparison = compute_delta(&effective_baseline, &effective_current);
8509
8510    let file_rows: Vec<CompareFileDeltaRow> = comparison
8511        .file_deltas
8512        .iter()
8513        .map(|d| CompareFileDeltaRow {
8514            relative_path: d.relative_path.clone(),
8515            language: d.language.clone().unwrap_or_else(|| "—".into()),
8516            status: match d.status {
8517                FileChangeStatus::Added => "added".into(),
8518                FileChangeStatus::Removed => "removed".into(),
8519                FileChangeStatus::Modified => "modified".into(),
8520                FileChangeStatus::Unchanged => "unchanged".into(),
8521            },
8522            baseline_code: d.baseline_code,
8523            current_code: d.current_code,
8524            baseline_code_display: if d.status == FileChangeStatus::Added {
8525                "—".into()
8526            } else {
8527                d.baseline_code.to_string()
8528            },
8529            current_code_display: if d.status == FileChangeStatus::Removed {
8530                "—".into()
8531            } else {
8532                d.current_code.to_string()
8533            },
8534            code_delta_str: fmt_delta(d.code_delta),
8535            code_delta_class: delta_class(d.code_delta).into(),
8536            comment_delta_str: fmt_delta(d.comment_delta),
8537            comment_delta_class: delta_class(d.comment_delta).into(),
8538            total_delta_str: fmt_delta(d.total_delta),
8539            total_delta_class: delta_class(d.total_delta).into(),
8540        })
8541        .collect();
8542
8543    let project_path = baseline_entry
8544        .input_roots
8545        .first()
8546        .map(|s| sanitize_path_str(s))
8547        .unwrap_or_default();
8548    let lines_added = sum_added_code_lines(&comparison);
8549    let lines_removed = sum_removed_code_lines(&comparison);
8550    let churn = compute_churn_stats(
8551        comparison.summary.baseline_code,
8552        comparison.summary.current_code,
8553        lines_added,
8554        lines_removed,
8555    );
8556    let s = &comparison.summary;
8557    let template = CompareTemplate {
8558        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8559        version: env!("CARGO_PKG_VERSION"),
8560        project_label: baseline_entry.project_label.clone(),
8561        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8562        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8563        baseline_run_id: baseline_entry.run_id.clone(),
8564        current_run_id: current_entry.run_id.clone(),
8565        baseline_run_id_short: baseline_entry
8566            .run_id
8567            .split('-')
8568            .next_back()
8569            .unwrap_or(&baseline_entry.run_id)
8570            .chars()
8571            .take(7)
8572            .collect(),
8573        current_run_id_short: current_entry
8574            .run_id
8575            .split('-')
8576            .next_back()
8577            .unwrap_or(&current_entry.run_id)
8578            .chars()
8579            .take(7)
8580            .collect(),
8581        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8582        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8583        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8584        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8585        project_path: project_path.clone(),
8586        baseline_code: s.baseline_code,
8587        current_code: s.current_code,
8588        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8589        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8590        baseline_files: s.baseline_files,
8591        current_files: s.current_files,
8592        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8593        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8594        baseline_comments: s.baseline_comments,
8595        current_comments: s.current_comments,
8596        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8597        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8598        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8599        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8600        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8601        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8602        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8603        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8604        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8605        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8606        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8607        code_lines_added: lines_added,
8608        code_lines_removed: lines_removed,
8609        code_lines_modified: sum_modified_code_lines(&comparison),
8610        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8611        code_lines_total: lines_added
8612            + lines_removed
8613            + sum_modified_code_lines(&comparison)
8614            + sum_unmodified_code_lines(&comparison),
8615        new_scope: churn.new_scope,
8616        churn_rate_str: churn.churn_rate_str,
8617        churn_rate_class: churn.churn_rate_class,
8618        scope_flag: churn.scope_flag,
8619        files_added: comparison.files_added,
8620        files_removed: comparison.files_removed,
8621        files_modified: comparison.files_modified,
8622        files_unchanged: comparison.files_unchanged,
8623        files_total: comparison.files_total,
8624        file_rows,
8625        baseline_git_author: baseline_entry.git_author.clone(),
8626        current_git_author: current_entry.git_author.clone(),
8627        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8628        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8629        baseline_git_tags: baseline_entry.git_tags.clone(),
8630        current_git_tags: current_entry.git_tags.clone(),
8631        baseline_git_commit_date: baseline_entry
8632            .git_commit_date
8633            .as_deref()
8634            .and_then(fmt_git_date),
8635        current_git_commit_date: current_entry
8636            .git_commit_date
8637            .as_deref()
8638            .and_then(fmt_git_date),
8639        project_name: project_path
8640            .rsplit(['/', '\\'])
8641            .find(|s| !s.is_empty())
8642            .unwrap_or(&project_path)
8643            .to_string(),
8644        submodule_options,
8645        has_any_submodule_data,
8646        active_submodule,
8647        super_scope_active,
8648        toast_assets: sloc_toast_assets(&csp_nonce),
8649        csp_nonce,
8650        coverage_delta_card: build_coverage_delta_card(s),
8651        baseline_test_count: effective_baseline.summary_totals.test_count,
8652        current_test_count: effective_current.summary_totals.test_count,
8653        baseline_coverage_pct: s.baseline_coverage_line_pct,
8654        current_coverage_pct: s.current_coverage_line_pct,
8655    };
8656
8657    Html(
8658        template
8659            .render()
8660            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8661    )
8662    .into_response()
8663}
8664
8665// ── Badge endpoint ────────────────────────────────────────────────────────────
8666// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8667// pages, Jira descriptions, etc.
8668//
8669// GET /badge/<metric>?label=<override>&color=<hex>
8670// Metrics: code-lines  files  comment-lines  blank-lines
8671
8672fn format_number(n: u64) -> String {
8673    let s = n.to_string();
8674    let mut out = String::with_capacity(s.len() + s.len() / 3);
8675    let len = s.len();
8676    for (i, c) in s.chars().enumerate() {
8677        if i > 0 && (len - i).is_multiple_of(3) {
8678            out.push(',');
8679        }
8680        out.push(c);
8681    }
8682    out
8683}
8684
8685const fn badge_char_width(c: char) -> f64 {
8686    match c {
8687        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8688        'm' | 'w' => 9.0,
8689        ' ' => 4.0,
8690        _ => 6.5,
8691    }
8692}
8693
8694#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8695fn badge_text_px(text: &str) -> u32 {
8696    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8697}
8698
8699fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8700    let lw = badge_text_px(label) + 20;
8701    let rw = badge_text_px(value) + 20;
8702    let total = lw + rw;
8703    let lx = lw / 2;
8704    let rx = lw + rw / 2;
8705    let le = escape_html(label);
8706    let ve = escape_html(value);
8707    let ce = escape_html(color);
8708    format!(
8709        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8710  <rect width="{total}" height="20" fill="#555"/>
8711  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8712  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8713    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8714    <text x="{lx}" y="13">{le}</text>
8715    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8716    <text x="{rx}" y="13">{ve}</text>
8717  </g>
8718</svg>"##
8719    )
8720}
8721
8722#[derive(Deserialize)]
8723struct BadgeQuery {
8724    label: Option<String>,
8725    color: Option<String>,
8726}
8727
8728async fn badge_handler(
8729    State(state): State<AppState>,
8730    AxumPath(metric): AxumPath<String>,
8731    Query(query): Query<BadgeQuery>,
8732) -> Response {
8733    let entry = {
8734        let reg = state.registry.lock().await;
8735        reg.entries.first().cloned()
8736    };
8737
8738    let Some(entry) = entry else {
8739        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8740        return (
8741            [
8742                (header::CONTENT_TYPE, "image/svg+xml"),
8743                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8744            ],
8745            svg,
8746        )
8747            .into_response();
8748    };
8749
8750    let (default_label, value, default_color) = match metric.as_str() {
8751        "code-lines" => (
8752            "code lines",
8753            format_number(entry.summary.code_lines),
8754            "#4a78ee",
8755        ),
8756        "files" => (
8757            "files analyzed",
8758            format_number(entry.summary.files_analyzed),
8759            "#4a9862",
8760        ),
8761        "comment-lines" => (
8762            "comment lines",
8763            format_number(entry.summary.comment_lines),
8764            "#b35428",
8765        ),
8766        "blank-lines" => (
8767            "blank lines",
8768            format_number(entry.summary.blank_lines),
8769            "#7a5db0",
8770        ),
8771        _ => return StatusCode::NOT_FOUND.into_response(),
8772    };
8773
8774    let label = query.label.as_deref().unwrap_or(default_label);
8775    let color = query.color.as_deref().unwrap_or(default_color);
8776    let svg = render_badge_svg(label, &value, color);
8777
8778    (
8779        [
8780            (header::CONTENT_TYPE, "image/svg+xml"),
8781            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8782        ],
8783        svg,
8784    )
8785        .into_response()
8786}
8787
8788// ── Metrics API ───────────────────────────────────────────────────────────────
8789// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8790// Confluence automation, Jira webhooks, etc.
8791//
8792// GET /api/metrics/latest
8793// GET /api/metrics/<run_id>
8794
8795#[derive(Serialize)]
8796struct ApiCoverageBlock {
8797    lines_found: u64,
8798    lines_hit: u64,
8799    line_pct: f64,
8800    functions_found: u64,
8801    functions_hit: u64,
8802    function_pct: f64,
8803    branches_found: u64,
8804    branches_hit: u64,
8805    branch_pct: f64,
8806}
8807
8808#[derive(Serialize)]
8809struct ApiMetricsResponse {
8810    run_id: String,
8811    timestamp: String,
8812    project: String,
8813    summary: ApiSummaryPayload,
8814    languages: Vec<ApiLanguageRow>,
8815    #[serde(skip_serializing_if = "Option::is_none")]
8816    coverage: Option<ApiCoverageBlock>,
8817}
8818
8819#[derive(Serialize)]
8820struct ApiSummaryPayload {
8821    files_analyzed: u64,
8822    files_skipped: u64,
8823    code_lines: u64,
8824    comment_lines: u64,
8825    blank_lines: u64,
8826    total_physical_lines: u64,
8827    functions: u64,
8828    classes: u64,
8829    variables: u64,
8830    imports: u64,
8831}
8832
8833#[derive(Serialize)]
8834struct ApiLanguageRow {
8835    name: String,
8836    files: u64,
8837    code_lines: u64,
8838    comment_lines: u64,
8839    blank_lines: u64,
8840    functions: u64,
8841    classes: u64,
8842    variables: u64,
8843    imports: u64,
8844}
8845
8846async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8847    let entry = {
8848        let reg = state.registry.lock().await;
8849        reg.entries.first().cloned()
8850    };
8851    entry.map_or_else(
8852        || error::not_found("no scans recorded yet"),
8853        |e| build_metrics_response(&e),
8854    )
8855}
8856
8857async fn api_metrics_run_handler(
8858    State(state): State<AppState>,
8859    AxumPath(run_id): AxumPath<String>,
8860) -> Response {
8861    let entry = {
8862        let reg = state.registry.lock().await;
8863        reg.find_by_run_id(&run_id).cloned()
8864    };
8865    entry.map_or_else(
8866        || error::not_found("run not found"),
8867        |e| build_metrics_response(&e),
8868    )
8869}
8870
8871fn build_metrics_response(entry: &RegistryEntry) -> Response {
8872    let languages: Vec<ApiLanguageRow> = entry
8873        .json_path
8874        .as_ref()
8875        .and_then(|p| read_json(p).ok())
8876        .map(|run| {
8877            run.totals_by_language
8878                .iter()
8879                .map(|l| ApiLanguageRow {
8880                    name: l.language.display_name().to_string(),
8881                    files: l.files,
8882                    code_lines: l.code_lines,
8883                    comment_lines: l.comment_lines,
8884                    blank_lines: l.blank_lines,
8885                    functions: l.functions,
8886                    classes: l.classes,
8887                    variables: l.variables,
8888                    imports: l.imports,
8889                })
8890                .collect()
8891        })
8892        .unwrap_or_default();
8893
8894    let s = &entry.summary;
8895    let coverage = if s.coverage_lines_found > 0 {
8896        let pct = |hit: u64, found: u64| -> f64 {
8897            if found == 0 {
8898                0.0
8899            } else {
8900                #[allow(clippy::cast_precision_loss)]
8901                let v = (hit as f64 / found as f64) * 100.0;
8902                (v * 10.0).round() / 10.0
8903            }
8904        };
8905        Some(ApiCoverageBlock {
8906            lines_found: s.coverage_lines_found,
8907            lines_hit: s.coverage_lines_hit,
8908            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8909            functions_found: s.coverage_functions_found,
8910            functions_hit: s.coverage_functions_hit,
8911            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8912            branches_found: s.coverage_branches_found,
8913            branches_hit: s.coverage_branches_hit,
8914            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8915        })
8916    } else {
8917        None
8918    };
8919    Json(ApiMetricsResponse {
8920        run_id: entry.run_id.clone(),
8921        timestamp: entry.timestamp_utc.to_rfc3339(),
8922        project: entry.project_label.clone(),
8923        summary: ApiSummaryPayload {
8924            files_analyzed: s.files_analyzed,
8925            files_skipped: s.files_skipped,
8926            code_lines: s.code_lines,
8927            comment_lines: s.comment_lines,
8928            blank_lines: s.blank_lines,
8929            total_physical_lines: s.total_physical_lines,
8930            functions: s.functions,
8931            classes: s.classes,
8932            variables: s.variables,
8933            imports: s.imports,
8934        },
8935        languages,
8936        coverage,
8937    })
8938    .into_response()
8939}
8940
8941// ── Project history API ───────────────────────────────────────────────────────
8942// Protected. Called by the wizard JS when the project path changes, so the UI
8943// can show a "scanned N times before" badge without a full page reload.
8944//
8945// GET /api/project-history?path=<project_root>
8946
8947#[derive(Deserialize)]
8948struct ProjectHistoryQuery {
8949    path: Option<String>,
8950}
8951
8952#[derive(Serialize)]
8953struct ProjectHistoryResponse {
8954    scan_count: usize,
8955    last_scan_id: Option<String>,
8956    last_scan_timestamp: Option<String>,
8957    last_scan_code_lines: Option<u64>,
8958    last_git_branch: Option<String>,
8959    last_git_commit: Option<String>,
8960}
8961
8962/// Return true if `entry` matches either an exact root path or an upload-staging
8963/// path with the same project name (needed because each upload gets a fresh UUID dir).
8964fn entry_matches_project(
8965    entry: &RegistryEntry,
8966    root_str: &str,
8967    upload_root: &str,
8968    upload_name_suffix: Option<&str>,
8969) -> bool {
8970    if entry.input_roots.iter().any(|r| r == root_str) {
8971        return true;
8972    }
8973    if let Some(suffix) = upload_name_suffix {
8974        return entry
8975            .input_roots
8976            .iter()
8977            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8978    }
8979    false
8980}
8981
8982async fn project_history_handler(
8983    State(state): State<AppState>,
8984    Query(query): Query<ProjectHistoryQuery>,
8985) -> Response {
8986    let path = query.path.unwrap_or_default();
8987    let resolved = resolve_input_path(&path);
8988    let root_str = resolved.to_string_lossy().replace('\\', "/");
8989
8990    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8991    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8992    // previous scans of the same project. Fall back to matching by project name within the
8993    // uploads staging directory so Scan History populates correctly across uploads.
8994    let upload_root = std::env::temp_dir()
8995        .join("oxide-sloc-uploads")
8996        .to_string_lossy()
8997        .replace('\\', "/");
8998    let upload_name_suffix: Option<String> =
8999        if state.server_mode && root_str.starts_with(&upload_root) {
9000            resolved
9001                .file_name()
9002                .and_then(|n| n.to_str())
9003                .map(|name| format!("/{name}"))
9004        } else {
9005            None
9006        };
9007    let suffix_ref = upload_name_suffix.as_deref();
9008
9009    let entries: Vec<_> = {
9010        let reg = state.registry.lock().await;
9011        reg.entries
9012            .iter()
9013            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9014            .cloned()
9015            .collect()
9016    };
9017    let scan_count = entries.len();
9018    let last = entries.first();
9019    let last_scan_id = last.map(|e| e.run_id.clone());
9020    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9021    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9022    let last_git_branch = last.and_then(|e| e.git_branch.clone());
9023    let last_git_commit = last.and_then(|e| e.git_commit.clone());
9024
9025    Json(ProjectHistoryResponse {
9026        scan_count,
9027        last_scan_id,
9028        last_scan_timestamp,
9029        last_scan_code_lines,
9030        last_git_branch,
9031        last_git_commit,
9032    })
9033    .into_response()
9034}
9035
9036// ── Metrics history API ───────────────────────────────────────────────────────
9037// Protected. Returns a JSON array of lightweight scan snapshots for plotting
9038// trend charts.
9039//
9040// GET /api/metrics/history?root=<path>&limit=<n>
9041
9042#[derive(Deserialize)]
9043struct MetricsHistoryQuery {
9044    root: Option<String>,
9045    limit: Option<usize>,
9046    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
9047    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
9048    submodule: Option<String>,
9049}
9050
9051#[derive(Serialize)]
9052struct MetricsSubmoduleLink {
9053    name: String,
9054    url: String,
9055}
9056
9057#[derive(Serialize)]
9058struct MetricsHistoryEntry {
9059    run_id: String,
9060    run_id_short: String,
9061    timestamp: String,
9062    commit: Option<String>,
9063    branch: Option<String>,
9064    tags: Vec<String>,
9065    nearest_tag: Option<String>,
9066    code_lines: u64,
9067    comment_lines: u64,
9068    blank_lines: u64,
9069    physical_lines: u64,
9070    files_analyzed: u64,
9071    files_skipped: u64,
9072    test_count: u64,
9073    project_label: String,
9074    html_url: Option<String>,
9075    has_pdf: bool,
9076    submodule_links: Vec<MetricsSubmoduleLink>,
9077    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
9078    #[serde(skip_serializing_if = "Option::is_none")]
9079    coverage_line_pct: Option<f64>,
9080}
9081
9082fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9083    let mut links: Vec<MetricsSubmoduleLink> = vec![];
9084    let sub_dir = e
9085        .html_path
9086        .as_ref()
9087        .and_then(|p| p.parent())
9088        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9089    let Some(dir) = sub_dir else { return links };
9090    let Ok(rd) = std::fs::read_dir(dir) else {
9091        return links;
9092    };
9093    for entry_res in rd.flatten() {
9094        let fname = entry_res.file_name();
9095        let fname_str = fname.to_string_lossy();
9096        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9097            let stem = &fname_str[..fname_str.len() - 5];
9098            let display = stem[4..].replace('-', " ");
9099            links.push(MetricsSubmoduleLink {
9100                name: display,
9101                url: format!("/runs/{stem}/{}", e.run_id),
9102            });
9103        }
9104    }
9105    links.sort_by(|a, b| a.name.cmp(&b.name));
9106    links
9107}
9108
9109fn apply_submodule_filter(
9110    base: MetricsHistoryEntry,
9111    filter: &str,
9112    e: &sloc_core::history::RegistryEntry,
9113) -> Option<MetricsHistoryEntry> {
9114    let json_path = e.json_path.as_ref()?;
9115    let json_str = std::fs::read_to_string(json_path).ok()?;
9116    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9117    let sub = run
9118        .submodule_summaries
9119        .iter()
9120        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9121    let safe = sanitize_project_label(&sub.name);
9122    let artifact_key = format!("sub_{safe}");
9123    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9124        || base.html_url.clone(),
9125        |run_dir| {
9126            let sub_path = run_dir.join(format!("{artifact_key}.html"));
9127            if sub_path.exists() {
9128                Some(format!("/runs/{artifact_key}/{}", e.run_id))
9129            } else {
9130                base.html_url.clone()
9131            }
9132        },
9133    );
9134
9135    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
9136    // basic SLOC totals, so test_count and coverage must be computed from file records.
9137    let sub_files: Vec<_> = run
9138        .per_file_records
9139        .iter()
9140        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9141        .collect();
9142    let test_count: u64 = sub_files
9143        .iter()
9144        .map(|r| r.raw_line_categories.test_count)
9145        .sum();
9146    #[allow(clippy::cast_precision_loss)]
9147    let coverage_line_pct: Option<f64> = {
9148        let found: u64 = sub_files
9149            .iter()
9150            .filter_map(|r| r.coverage.as_ref())
9151            .map(|c| u64::from(c.lines_found))
9152            .sum();
9153        let hit: u64 = sub_files
9154            .iter()
9155            .filter_map(|r| r.coverage.as_ref())
9156            .map(|c| u64::from(c.lines_hit))
9157            .sum();
9158        if found > 0 {
9159            let pct = (hit as f64 / found as f64) * 100.0;
9160            Some((pct * 10.0).round() / 10.0)
9161        } else {
9162            None
9163        }
9164    };
9165
9166    Some(MetricsHistoryEntry {
9167        code_lines: sub.code_lines,
9168        comment_lines: sub.comment_lines,
9169        blank_lines: sub.blank_lines,
9170        physical_lines: sub.total_physical_lines,
9171        files_analyzed: sub.files_analyzed,
9172        files_skipped: 0,
9173        test_count,
9174        html_url: sub_html_url,
9175        has_pdf: false,
9176        submodule_links: vec![],
9177        coverage_line_pct,
9178        ..base
9179    })
9180}
9181
9182#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
9183async fn api_metrics_history_handler(
9184    State(state): State<AppState>,
9185    Query(query): Query<MetricsHistoryQuery>,
9186) -> Response {
9187    let limit = query.limit.unwrap_or(50).min(500);
9188    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9189
9190    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9191        let reg = state.registry.lock().await;
9192        reg.entries
9193            .iter()
9194            .filter(|e| {
9195                query.root.as_ref().is_none_or(|root| {
9196                    let resolved = resolve_input_path(root);
9197                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9198                    e.input_roots.iter().any(|r| r == &root_str)
9199                })
9200            })
9201            .take(limit)
9202            .cloned()
9203            .collect()
9204    };
9205
9206    let entries: Vec<MetricsHistoryEntry> = candidate_entries
9207        .into_iter()
9208        .filter_map(|e| {
9209            let tags = e
9210                .git_tags
9211                .as_deref()
9212                .map(|s| {
9213                    s.split(',')
9214                        .map(|t| t.trim().to_string())
9215                        .filter(|t| !t.is_empty())
9216                        .collect()
9217                })
9218                .unwrap_or_default();
9219            let html_url = e
9220                .html_path
9221                .as_ref()
9222                .filter(|p| p.exists())
9223                .map(|_| format!("/runs/html/{}", e.run_id));
9224            let nearest_tag = e.git_nearest_tag.clone();
9225            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9226            let run_id_short: String = e
9227                .run_id
9228                .split('-')
9229                .next_back()
9230                .unwrap_or(&e.run_id)
9231                .chars()
9232                .take(7)
9233                .collect();
9234            let submodule_links = build_entry_submodule_links(&e);
9235            #[allow(clippy::cast_precision_loss)]
9236            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9237                let pct = (e.summary.coverage_lines_hit as f64
9238                    / e.summary.coverage_lines_found as f64)
9239                    * 100.0;
9240                Some((pct * 10.0).round() / 10.0)
9241            } else {
9242                None
9243            };
9244            let base = MetricsHistoryEntry {
9245                run_id: e.run_id.clone(),
9246                run_id_short,
9247                timestamp: e.timestamp_utc.to_rfc3339(),
9248                commit: e.git_commit.clone(),
9249                branch: e.git_branch.clone(),
9250                tags,
9251                nearest_tag,
9252                code_lines: e.summary.code_lines,
9253                comment_lines: e.summary.comment_lines,
9254                blank_lines: e.summary.blank_lines,
9255                physical_lines: e.summary.total_physical_lines,
9256                files_analyzed: e.summary.files_analyzed,
9257                files_skipped: e.summary.files_skipped,
9258                test_count: e.summary.test_count,
9259                project_label: e.project_label.clone(),
9260                html_url,
9261                has_pdf,
9262                submodule_links,
9263                coverage_line_pct,
9264            };
9265            if let Some(ref filter) = submodule_filter {
9266                apply_submodule_filter(base, filter, &e)
9267            } else {
9268                Some(base)
9269            }
9270        })
9271        .collect();
9272
9273    Json(entries).into_response()
9274}
9275
9276/// One scan's code churn versus the previous scan of the same project.
9277#[derive(Serialize)]
9278struct ChurnEntry {
9279    run_id: String,
9280    added: i64,
9281    removed: i64,
9282    modified: i64,
9283    unmodified: i64,
9284}
9285
9286// GET /api/metrics/churn?root=<path>&limit=<n>
9287// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
9288// comparing each scan to the previous scan of the same project. Loads per-file JSON
9289// artifacts, so it is intended for export-time use rather than every page load.
9290async fn api_metrics_churn_handler(
9291    State(state): State<AppState>,
9292    Query(query): Query<MetricsHistoryQuery>,
9293) -> Response {
9294    let limit = query.limit.unwrap_or(200).min(500);
9295    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9296        let reg = state.registry.lock().await;
9297        reg.entries
9298            .iter()
9299            .filter(|e| {
9300                query.root.as_ref().is_none_or(|root| {
9301                    let resolved = resolve_input_path(root);
9302                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9303                    e.input_roots.iter().any(|r| r == &root_str)
9304                })
9305            })
9306            .take(limit)
9307            .cloned()
9308            .collect()
9309    };
9310    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9311        std::collections::HashMap::new();
9312    for e in candidate_entries {
9313        by_project
9314            .entry(e.project_label.clone())
9315            .or_default()
9316            .push(e);
9317    }
9318    let mut out: Vec<ChurnEntry> = Vec::new();
9319    for (_proj, mut entries) in by_project {
9320        entries.sort_by_key(|e| e.timestamp_utc);
9321        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9322        for e in &entries {
9323            let curr = e
9324                .json_path
9325                .as_ref()
9326                .and_then(|path| sloc_core::read_json(path).ok());
9327            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9328                let cmp = sloc_core::compute_delta(prev, cur);
9329                out.push(ChurnEntry {
9330                    run_id: e.run_id.clone(),
9331                    added: sum_added_code_lines(&cmp),
9332                    removed: sum_removed_code_lines(&cmp),
9333                    modified: sum_modified_code_lines(&cmp),
9334                    unmodified: sum_unmodified_code_lines(&cmp),
9335                });
9336            } else {
9337                out.push(ChurnEntry {
9338                    run_id: e.run_id.clone(),
9339                    added: 0,
9340                    removed: 0,
9341                    modified: 0,
9342                    unmodified: 0,
9343                });
9344            }
9345            if curr.is_some() {
9346                prev_run = curr;
9347            }
9348        }
9349    }
9350    Json(out).into_response()
9351}
9352
9353// GET /api/metrics/submodules?root=<path>
9354// Returns the union of distinct submodule names found across all saved scan JSON artifacts
9355// for the given project root (or all roots if omitted).
9356#[derive(Deserialize)]
9357struct MetricsSubmodulesQuery {
9358    root: Option<String>,
9359}
9360
9361#[derive(Serialize)]
9362struct SubmoduleEntry {
9363    name: String,
9364    relative_path: String,
9365}
9366
9367async fn api_metrics_submodules_handler(
9368    State(state): State<AppState>,
9369    Query(query): Query<MetricsSubmodulesQuery>,
9370) -> Response {
9371    let json_paths: Vec<std::path::PathBuf> = {
9372        let reg = state.registry.lock().await;
9373        reg.entries
9374            .iter()
9375            .filter(|e| {
9376                query.root.as_ref().is_none_or(|root| {
9377                    let resolved = resolve_input_path(root);
9378                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9379                    e.input_roots.iter().any(|r| r == &root_str)
9380                })
9381            })
9382            .filter_map(|e| e.json_path.clone())
9383            .collect()
9384    };
9385
9386    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9387    let mut result: Vec<SubmoduleEntry> = Vec::new();
9388
9389    for path in &json_paths {
9390        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9391            continue;
9392        };
9393        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9394            continue;
9395        };
9396        for sub in &run.submodule_summaries {
9397            if seen.insert(sub.name.clone()) {
9398                result.push(SubmoduleEntry {
9399                    name: sub.name.clone(),
9400                    relative_path: sub.relative_path.clone(),
9401                });
9402            }
9403        }
9404    }
9405
9406    result.sort_by(|a, b| a.name.cmp(&b.name));
9407    Json(result).into_response()
9408}
9409
9410// ── CI ingest endpoint ────────────────────────────────────────────────────────
9411// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
9412// server stores and displays results without cloning or scanning anything itself.
9413//
9414// POST /api/ingest?label=<optional_display_name>
9415// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
9416// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
9417
9418#[derive(Deserialize)]
9419struct IngestQuery {
9420    label: Option<String>,
9421}
9422
9423#[derive(Serialize)]
9424struct IngestResponse {
9425    run_id: String,
9426    view_url: String,
9427}
9428
9429async fn api_ingest_handler(
9430    State(state): State<AppState>,
9431    Query(q): Query<IngestQuery>,
9432    Json(run): Json<sloc_core::AnalysisRun>,
9433) -> Response {
9434    let label = q.label.unwrap_or_else(|| {
9435        run.input_roots
9436            .first()
9437            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9438    });
9439
9440    let label_for_task = label.clone();
9441    let result = tokio::task::spawn_blocking(move || {
9442        let html = render_html(&run)?;
9443        let run_id = run.tool.run_id.clone();
9444        let run_id_safe = run_id.len() <= 128
9445            && !run_id.is_empty()
9446            && run_id
9447                .chars()
9448                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9449        if !run_id_safe {
9450            anyhow::bail!(
9451                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9452            );
9453        }
9454        let project_label = sanitize_project_label(&label_for_task);
9455        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9456        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9457            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9458            _ => project_label,
9459        };
9460        let (artifacts, _pending_pdf) = persist_run_artifacts(
9461            &run,
9462            &html,
9463            &output_dir,
9464            &label_for_task,
9465            &file_stem,
9466            RunResultContext::default(),
9467        )?;
9468        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9469    })
9470    .await;
9471
9472    match result {
9473        Ok(Ok((run_id, artifacts, run))) => {
9474            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9475            (
9476                StatusCode::CREATED,
9477                Json(IngestResponse {
9478                    view_url: format!("/view-reports?run_id={run_id}"),
9479                    run_id,
9480                }),
9481            )
9482                .into_response()
9483        }
9484        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9485        Err(e) => error::internal(&format!("{e}")),
9486    }
9487}
9488
9489// ── Multi-compare page ────────────────────────────────────────────────────────
9490// GET /multi-compare?runs=id1,id2,id3,...
9491
9492fn html_escape(s: &str) -> String {
9493    s.replace('&', "&amp;")
9494        .replace('<', "&lt;")
9495        .replace('>', "&gt;")
9496        .replace('"', "&quot;")
9497}
9498
9499#[allow(clippy::cast_precision_loss)]
9500fn fmt_num(n: i64) -> String {
9501    let a = n.unsigned_abs();
9502    if a >= 1_000_000 {
9503        let v = n as f64 / 1_000_000.0;
9504        let s = format!("{v:.1}");
9505        format!("{}M", s.trim_end_matches(".0"))
9506    } else if a >= 10_000 {
9507        let v = n as f64 / 1_000.0;
9508        let s = format!("{v:.1}");
9509        format!("{}K", s.trim_end_matches(".0"))
9510    } else {
9511        let sign = if n < 0 { "-" } else { "" };
9512        if a < 1_000 {
9513            return format!("{sign}{a}");
9514        }
9515        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9516    }
9517}
9518
9519fn fmt_comma(n: i64) -> String {
9520    let sign = if n < 0 { "-" } else { "" };
9521    let a = n.unsigned_abs();
9522    if a < 1_000 {
9523        return format!("{sign}{a}");
9524    }
9525    let s = a.to_string();
9526    let bytes = s.as_bytes();
9527    let len = bytes.len();
9528    let mut out = String::with_capacity(len + len / 3);
9529    for (i, &b) in bytes.iter().enumerate() {
9530        if i > 0 && (len - i).is_multiple_of(3) {
9531            out.push(',');
9532        }
9533        out.push(b as char);
9534    }
9535    format!("{sign}{out}")
9536}
9537
9538/// Insert thousands separators into the integer portion of a number's textual form.
9539///
9540/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9541/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9542/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9543/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9544fn group_thousands(s: &str) -> String {
9545    let (sign, rest) = match s.as_bytes().first() {
9546        Some(b'-') => ("-", &s[1..]),
9547        Some(b'+') => ("+", &s[1..]),
9548        _ => ("", s),
9549    };
9550    let (int_part, frac_part) = match rest.split_once('.') {
9551        Some((i, f)) => (i, Some(f)),
9552        None => (rest, None),
9553    };
9554    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9555        return s.to_string();
9556    }
9557    let bytes = int_part.as_bytes();
9558    let len = bytes.len();
9559    let mut grouped = String::with_capacity(len + len / 3);
9560    for (i, &b) in bytes.iter().enumerate() {
9561        if i > 0 && (len - i).is_multiple_of(3) {
9562            grouped.push(',');
9563        }
9564        grouped.push(b as char);
9565    }
9566    frac_part.map_or_else(
9567        || format!("{sign}{grouped}"),
9568        |f| format!("{sign}{grouped}.{f}"),
9569    )
9570}
9571
9572/// Custom Askama filters available to templates in this crate.
9573mod filters {
9574    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9575    // (a `&self` `execute` method returning `Result`), not on our own source.
9576    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9577    use askama::{Result, Values};
9578
9579    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9580    ///
9581    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9582    /// (dashes, "No prior scan", etc.) passes through untouched.
9583    #[askama::filter_fn]
9584    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9585        Ok(super::group_thousands(&value.to_string()))
9586    }
9587}
9588
9589#[derive(Deserialize, Default)]
9590struct MultiCompareQuery {
9591    runs: Option<String>,
9592    /// "super" to show only super-repo files (exclude all submodule files)
9593    scope: Option<String>,
9594    /// Submodule name to narrow the comparison to one submodule
9595    sub: Option<String>,
9596}
9597
9598#[allow(clippy::too_many_lines)]
9599async fn multi_compare_handler(
9600    State(state): State<AppState>,
9601    Query(params): Query<MultiCompareQuery>,
9602    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9603) -> impl IntoResponse {
9604    let run_ids: Vec<String> = params
9605        .runs
9606        .as_deref()
9607        .unwrap_or("")
9608        .split(',')
9609        .map(|s| s.trim().to_string())
9610        .filter(|s| !s.is_empty())
9611        .collect();
9612
9613    if run_ids.len() < 2 {
9614        return Html(
9615            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9616             <a href=\"/compare-scans\">Go back</a></p>",
9617        )
9618        .into_response();
9619    }
9620    if run_ids.len() > 20 {
9621        return Html(
9622            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9623             at once. <a href=\"/compare-scans\">Go back</a></p>",
9624        )
9625        .into_response();
9626    }
9627
9628    // Look up each run_id in the registry.
9629    let entries: Vec<Option<RegistryEntry>> = {
9630        let reg = state.registry.lock().await;
9631        run_ids
9632            .iter()
9633            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9634            .collect()
9635    };
9636
9637    for (i, entry) in entries.iter().enumerate() {
9638        if entry.is_none() {
9639            let html = format!(
9640                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9641                 found. <a href=\"/compare-scans\">Go back</a></p>",
9642                run_ids[i]
9643            );
9644            return Html(html).into_response();
9645        }
9646    }
9647
9648    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9649
9650    for entry in &entries {
9651        if entry.json_path.is_none() {
9652            let html = format!(
9653                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9654                 JSON data — re-run the analysis to enable comparison. \
9655                 <a href=\"/compare-scans\">Go back</a></p>",
9656                entry.run_id
9657            );
9658            return Html(html).into_response();
9659        }
9660    }
9661
9662    // Sort chronologically.
9663    entries.sort_by_key(|e| e.timestamp_utc);
9664
9665    // Load JSON for each entry.
9666    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9667    for entry in &entries {
9668        let path = entry.json_path.as_ref().unwrap();
9669        match read_json(path) {
9670            Ok(r) => runs.push(r),
9671            Err(e) => {
9672                let html = format!(
9673                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9674                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9675                    entry.run_id
9676                );
9677                return Html(html).into_response();
9678            }
9679        }
9680    }
9681
9682    // Collect submodule names from all runs.
9683    let all_sub_names: Vec<String> = {
9684        let mut set = std::collections::BTreeSet::new();
9685        for r in &runs {
9686            for s in &r.submodule_summaries {
9687                set.insert(s.name.clone());
9688            }
9689        }
9690        set.into_iter().collect()
9691    };
9692    let has_submodule_data = !all_sub_names.is_empty();
9693    let active_submodule = params.sub.clone();
9694    let super_scope_active = params.scope.as_deref() == Some("super");
9695
9696    // Narrow per_file_records when a scope is active, then recompute totals.
9697    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9698
9699    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9700    let project_label = entries
9701        .first()
9702        .map_or("", |e| e.project_label.as_str())
9703        .to_string();
9704    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9705    let multi = compute_multi_delta(&run_refs);
9706    let html = multi_compare_page(
9707        &multi,
9708        &project_label,
9709        env!("CARGO_PKG_VERSION"),
9710        &csp_nonce,
9711        has_submodule_data,
9712        &all_sub_names,
9713        &runs_csv,
9714        super_scope_active,
9715        active_submodule.as_deref(),
9716        &entries,
9717    );
9718    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9719    // copy after a rebuild would silently mask UI fixes.
9720    (
9721        [(axum::http::header::CACHE_CONTROL, "no-store")],
9722        Html(html),
9723    )
9724        .into_response()
9725}
9726
9727const fn multi_delta_class(n: i64) -> &'static str {
9728    match n {
9729        1.. => "pos",
9730        ..=-1 => "neg",
9731        0 => "zero",
9732    }
9733}
9734
9735fn multi_fmt_delta(n: i64) -> String {
9736    if n > 0 {
9737        format!("+{n}")
9738    } else {
9739        format!("{n}")
9740    }
9741}
9742
9743/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9744fn js_escape(s: &str) -> String {
9745    use std::fmt::Write as _;
9746    let mut out = String::with_capacity(s.len() + 2);
9747    for c in s.chars() {
9748        match c {
9749            '"' => out.push_str("\\\""),
9750            '\\' => out.push_str("\\\\"),
9751            '\n' => out.push_str("\\n"),
9752            '\r' => out.push_str("\\r"),
9753            '\t' => out.push_str("\\t"),
9754            c if (c as u32) < 0x20 => {
9755                let _ = write!(out, "\\u{:04x}", c as u32);
9756            }
9757            c => out.push(c),
9758        }
9759    }
9760    out
9761}
9762
9763/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9764fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9765    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9766        return (
9767            "&mdash;".to_string(),
9768            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9769        );
9770    };
9771    let cd = entry
9772        .git_commit_date
9773        .as_deref()
9774        .and_then(fmt_git_date)
9775        .unwrap_or_else(|| "&mdash;".to_string());
9776    let au = entry.git_author.as_deref().map_or_else(
9777        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9778        |a| {
9779            format!(
9780                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9781                 <span class=\"cmp-author-handle\"></span></span>",
9782                html_escape(a)
9783            )
9784        },
9785    );
9786    (cd, au)
9787}
9788
9789/// Render the scope badge chip for a scan card header.
9790fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9791    active_sub.map_or_else(
9792        || {
9793            if super_scope_active {
9794                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9795            } else {
9796                "<span class=\"mc-scope-tag mc-scope-full\">\
9797                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9798                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9799                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9800                 <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\
9801                 </svg> Full scan</span>"
9802                    .to_string()
9803            }
9804        },
9805        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9806    )
9807}
9808
9809/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9810fn build_mc_scan_strip(
9811    multi: &MultiScanComparison,
9812    entries: &[RegistryEntry],
9813    n: usize,
9814    is_many: bool,
9815    active_sub: Option<&str>,
9816    super_scope_active: bool,
9817    project_label: &str,
9818) -> String {
9819    use std::fmt::Write as _;
9820    let mut scan_strip = String::new();
9821    for (i, pt) in multi.points.iter().enumerate() {
9822        let ts_ms = pt.timestamp.timestamp_millis();
9823        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9824        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9825        let branch = pt.git_branch.as_deref().unwrap_or("");
9826        let report_link = format!("/runs/html/{}", pt.run_id);
9827        let branch_html = if branch.is_empty() {
9828            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9829        } else {
9830            format!(
9831                "<span class=\"mc-card-branch\">{}</span>",
9832                html_escape(branch)
9833            )
9834        };
9835        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9836        let tags_html = pt
9837            .git_tags
9838            .as_deref()
9839            .filter(|t| !t.is_empty())
9840            .map(|t| {
9841                let chips = t
9842                    .split(',')
9843                    .filter(|s| !s.is_empty())
9844                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9845                    .collect::<Vec<_>>()
9846                    .join(" ");
9847                format!(
9848                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9849                     <span class=\"mc-row-val\">{chips}</span></div>"
9850                )
9851            })
9852            .unwrap_or_default();
9853        let nearest = pt
9854            .git_nearest_tag
9855            .as_deref()
9856            .map(|t| format!("near {}", html_escape(t)))
9857            .unwrap_or_default();
9858        let arrow = if i < n - 1 && !is_many {
9859            "<div class='mc-arrow'>&#8594;</div>"
9860        } else {
9861            ""
9862        };
9863        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9864        let nearest_html = if nearest.is_empty() {
9865            String::new()
9866        } else {
9867            format!(
9868                "<span class=\"mc-card-nearest-wrap\">\
9869                 <span class=\"mc-card-nearest\">{nearest}</span>\
9870                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9871                 </span>"
9872            )
9873        };
9874        write!(
9875            scan_strip,
9876            r#"<div class="mc-card">
9877              <div class="mc-card-header">
9878                <div class="mc-card-num">Scan {num}</div>
9879                <div class="mc-card-project-col">
9880                  <div class="mc-card-project">{project_label}</div>
9881                  {scope_badge}
9882                </div>
9883              </div>
9884              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9885              <div class="mc-card-rows">
9886                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9887                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9888                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9889                <div class="mc-card-row"><span class="mc-row-label">Scanned on:</span><span class="mc-row-val mc-ts-local" data-utc-ms="{ts_ms}">{ts}</span></div>
9890                {tags_html}
9891              </div>
9892              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9893            </div>{arrow}"#,
9894            num = i + 1,
9895            commit = html_escape(commit),
9896            commit_date = commit_date_html,
9897            ts_ms = ts_ms,
9898            code = fmt_num(pt.code_lines),
9899            scope_badge = scope_badge,
9900            nearest_html = nearest_html,
9901        )
9902        .unwrap();
9903    }
9904    scan_strip
9905}
9906
9907/// Build the metric progression table (thead + tbody) for multi-compare.
9908#[allow(clippy::too_many_lines)]
9909fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9910    use std::fmt::Write as _;
9911    struct MetricRow<'a> {
9912        label: &'a str,
9913        values: Vec<i64>,
9914        seq_deltas: Vec<i64>,
9915        net_delta: i64,
9916    }
9917    let rows: Vec<MetricRow<'_>> = vec![
9918        MetricRow {
9919            label: "Code Lines",
9920            values: multi.points.iter().map(|p| p.code_lines).collect(),
9921            seq_deltas: multi
9922                .sequential_deltas
9923                .iter()
9924                .map(|d| d.summary.code_lines_delta)
9925                .collect(),
9926            net_delta: multi.total_delta.code_lines_delta,
9927        },
9928        MetricRow {
9929            label: "Files Analyzed",
9930            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9931            seq_deltas: multi
9932                .sequential_deltas
9933                .iter()
9934                .map(|d| d.summary.files_analyzed_delta)
9935                .collect(),
9936            net_delta: multi.total_delta.files_analyzed_delta,
9937        },
9938        MetricRow {
9939            label: "Comment Lines",
9940            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9941            seq_deltas: multi
9942                .sequential_deltas
9943                .iter()
9944                .map(|d| d.summary.comment_lines_delta)
9945                .collect(),
9946            net_delta: multi.total_delta.comment_lines_delta,
9947        },
9948        MetricRow {
9949            label: "Blank Lines",
9950            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9951            seq_deltas: multi
9952                .sequential_deltas
9953                .iter()
9954                .map(|d| d.summary.blank_lines_delta)
9955                .collect(),
9956            net_delta: multi.total_delta.blank_lines_delta,
9957        },
9958        MetricRow {
9959            label: "Tests",
9960            values: multi.points.iter().map(|p| p.test_count).collect(),
9961            seq_deltas: multi
9962                .points
9963                .windows(2)
9964                .map(|pts| pts[1].test_count - pts[0].test_count)
9965                .collect(),
9966            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9967                - multi.points.first().map_or(0, |f| f.test_count),
9968        },
9969    ];
9970    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9971    for i in 0..n {
9972        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9973        if i < n - 1 {
9974            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9975        }
9976    }
9977    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9978    let mut metrics_tbody = String::new();
9979    for row in &rows {
9980        metrics_tbody.push_str("<tr>");
9981        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9982        for i in 0..n {
9983            write!(
9984                metrics_tbody,
9985                "<td class='mc-val-col'>{}</td>",
9986                fmt_comma(row.values[i])
9987            )
9988            .unwrap();
9989            if i < n - 1 {
9990                let d = row.seq_deltas[i];
9991                write!(
9992                    metrics_tbody,
9993                    "<td class='mc-delta-col {cls}'>{val}</td>",
9994                    cls = multi_delta_class(d),
9995                    val = multi_fmt_delta(d)
9996                )
9997                .unwrap();
9998            }
9999        }
10000        let nd = row.net_delta;
10001        write!(
10002            metrics_tbody,
10003            "<td class='mc-net-col {cls}'>{val}</td>",
10004            cls = multi_delta_class(nd),
10005            val = multi_fmt_delta(nd)
10006        )
10007        .unwrap();
10008        metrics_tbody.push_str("</tr>");
10009    }
10010    (metrics_thead, metrics_tbody)
10011}
10012
10013/// Build the JS-embeddable points JSON array for the multi-compare chart.
10014fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10015    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10016    for (i, pt) in multi.points.iter().enumerate() {
10017        let commit = pt.git_commit.as_deref().unwrap_or("");
10018        let branch = pt.git_branch.as_deref().unwrap_or("");
10019        let tags = pt.git_tags.as_deref().unwrap_or("");
10020        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10021        let scanned_ms = pt.timestamp.timestamp_millis();
10022        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10023        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10024        let commit_date = entry
10025            .and_then(|e| e.git_commit_date.as_deref())
10026            .and_then(fmt_git_date)
10027            .unwrap_or_default();
10028        let author = entry
10029            .and_then(|e| e.git_author.as_deref())
10030            .unwrap_or("")
10031            .to_string();
10032        let cov = pt
10033            .coverage_line_pct
10034            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10035        parts.push(format!(
10036            r#"{{"run_id":"{run_id}","commit":"{commit}","branch":"{branch}","tags":"{tags}","nearest":"{nearest}","commit_date":"{commit_date}","author":"{author}","scanned":"{scanned}","scanned_ms":{scanned_ms},"code":{code},"comments":{comments},"blank":{blank},"files":{files},"tests":{tests},"cov":{cov}}}"#,
10037            run_id = js_escape(&pt.run_id),
10038            commit = js_escape(commit),
10039            branch = js_escape(branch),
10040            tags = js_escape(tags),
10041            nearest = js_escape(nearest),
10042            commit_date = js_escape(&commit_date),
10043            author = js_escape(&author),
10044            scanned = js_escape(&scanned),
10045            code = pt.code_lines,
10046            comments = pt.comment_lines,
10047            blank = pt.blank_lines,
10048            files = pt.files_analyzed,
10049            tests = pt.test_count,
10050        ));
10051    }
10052    format!("[{}]", parts.join(","))
10053}
10054
10055/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
10056fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10057    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10058    for row in &multi.file_matrix {
10059        let lang = row.language.as_deref().unwrap_or("");
10060        let codes: Vec<String> = row
10061            .code_per_scan
10062            .iter()
10063            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10064            .collect();
10065        let deltas: Vec<String> = row
10066            .code_delta_per_scan
10067            .iter()
10068            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10069            .collect();
10070        parts.push(format!(
10071            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10072            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10073            status = row.overall_status,
10074            codes = codes.join(","),
10075            deltas = deltas.join(","),
10076            total = row.total_code_delta,
10077        ));
10078    }
10079    format!("[{}]", parts.join(","))
10080}
10081
10082/// Build the column header cells for the file-matrix table.
10083fn build_mc_file_col_headers(n: usize) -> String {
10084    use std::fmt::Write as _;
10085    let mut out = String::new();
10086    for i in 0..n {
10087        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10088        if i < n - 1 {
10089            write!(
10090                out,
10091                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
10092                i + 2
10093            )
10094            .unwrap();
10095        }
10096    }
10097    out
10098}
10099
10100/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
10101fn build_mc_scope_bar(
10102    has_submodule_data: bool,
10103    sub_names: &[String],
10104    runs_csv: &str,
10105    active_sub: Option<&str>,
10106    super_scope_active: bool,
10107) -> String {
10108    use std::fmt::Write as _;
10109    if !has_submodule_data {
10110        return String::new();
10111    }
10112    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10113    let full_active = active_sub.is_none() && !super_scope_active;
10114    let mut bar = format!(
10115        r#"<div class="submod-scope-bar">
10116  <span class="submod-scope-label">
10117    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"></path></svg>
10118    Scope:
10119  </span>
10120  <div class="submod-scope-divider"></div>
10121  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10122  <a class="submod-scope-btn{super_cls}" href="{base_url}&amp;scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
10123        full_cls = if full_active { " active" } else { "" },
10124        super_cls = if super_scope_active { " active" } else { "" },
10125    );
10126    for s in sub_names {
10127        let is_active = active_sub == Some(s.as_str());
10128        write!(
10129            bar,
10130            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10131            cls = if is_active { " active" } else { "" },
10132            name_enc = html_escape(s),
10133            name_esc = html_escape(s),
10134        )
10135        .unwrap();
10136    }
10137    bar.push_str("\n</div>");
10138    bar
10139}
10140
10141/// Build the scope-description label shown in the page subtitle.
10142fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10143    active_sub.map_or_else(
10144        || {
10145            if super_scope_active {
10146                "Super-repo only &mdash; ".to_string()
10147            } else {
10148                String::new()
10149            }
10150        },
10151        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
10152    )
10153}
10154
10155#[allow(clippy::too_many_lines)]
10156#[allow(clippy::too_many_arguments)]
10157fn multi_compare_page(
10158    multi: &MultiScanComparison,
10159    project_label: &str,
10160    version: &str,
10161    csp_nonce: &str,
10162    has_submodule_data: bool,
10163    sub_names: &[String],
10164    runs_csv: &str,
10165    super_scope_active: bool,
10166    active_sub: Option<&str>,
10167    entries: &[RegistryEntry],
10168) -> String {
10169    let n = multi.points.len();
10170    let is_many = n > 4;
10171    let mc_strip_class = if is_many {
10172        "mc-strip mc-strip-grid"
10173    } else {
10174        "mc-strip"
10175    };
10176
10177    // ── Scan strip cards ──────────────────────────────────────────────────────
10178    let scan_strip = build_mc_scan_strip(
10179        multi,
10180        entries,
10181        n,
10182        is_many,
10183        active_sub,
10184        super_scope_active,
10185        project_label,
10186    );
10187
10188    // ── Summary metrics table ─────────────────────────────────────────────────
10189    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10190
10191    // ── Chart data and table helpers ──────────────────────────────────────────
10192    let points_json = build_mc_points_json(multi, entries);
10193    let file_matrix_json = build_mc_file_matrix_json(multi);
10194
10195    // Counts for filter tabs
10196    let files_modified = multi
10197        .file_matrix
10198        .iter()
10199        .filter(|f| f.overall_status == "modified")
10200        .count();
10201    let files_added = multi
10202        .file_matrix
10203        .iter()
10204        .filter(|f| f.overall_status == "added")
10205        .count();
10206    let files_removed = multi
10207        .file_matrix
10208        .iter()
10209        .filter(|f| f.overall_status == "removed")
10210        .count();
10211    let files_unchanged = multi
10212        .file_matrix
10213        .iter()
10214        .filter(|f| f.overall_status == "unchanged")
10215        .count();
10216    let total_files = multi.file_matrix.len();
10217
10218    let file_col_headers = build_mc_file_col_headers(n);
10219    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10220    let scope_bar_html = build_mc_scope_bar(
10221        has_submodule_data,
10222        sub_names,
10223        runs_csv,
10224        active_sub,
10225        super_scope_active,
10226    );
10227    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10228    let toast_assets = sloc_toast_assets(csp_nonce);
10229
10230    format!(
10231        r#"<!doctype html>
10232<html lang="en">
10233<head>
10234  <meta charset="utf-8">
10235  <meta name="viewport" content="width=device-width, initial-scale=1">
10236  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10237  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10238  <style nonce="{csp_nonce}">
10239    :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
10240    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10241    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10242    body.dark-theme{{--bg:#1a120b;--surface:#241a12;--surface-2:#2d2117;--line:#3d2e22;--line-strong:#54402f;--text:#f0e6dc;--muted:#b09080;--muted-2:#8a6e5f;--pos-bg:#163a23;--neg-bg:#3d1c1c;}}
10243    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10244    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10245    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10246    .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
10247    @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
10248    .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
10249    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}}
10250    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10251    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10252    @media(max-width:1150px){{.nav-right{{gap:4px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 8px;font-size:11px;min-height:34px;}}.brand-subtitle{{display:none;}}}}
10253    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10254    .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
10255    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10256    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10257    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10258    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10259    .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
10260    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10261    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10262    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10263    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10264    .nav-dropdown{{position:relative;display:inline-flex;}}
10265    .nav-dropdown-btn{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;cursor:pointer;transition:background .15s ease,transform .15s ease;}}
10266    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10267    .nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity .13s,visibility 0s .13s;}}
10268    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10269    .nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}
10270    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10271    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10272    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10273    body:not(.dark-theme) .icon-sun{{display:none;}}
10274    body.dark-theme .icon-moon{{display:none;}}
10275    .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
10276    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10277    .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
10278    .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
10279    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10280    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10281    .settings-modal-body{{padding:14px 16px 16px;}}
10282    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10283    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10284    .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
10285    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10286    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10287    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10288    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10289    .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
10290    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10291    .btn-back{{display:inline-flex;align-items:center;gap:7px;padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s;white-space:nowrap;margin-bottom:16px;}}
10292    .btn-back:hover{{background:var(--line);}}
10293    .mc-title{{font-size:28px;font-weight:900;letter-spacing:-.03em;margin:0 0 6px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
10294    body.dark-theme .mc-title{{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
10295    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10296    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10297    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10298    .mc-strip.mc-strip-grid{{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;overflow:visible;padding:8px 4px 6px;}}
10299    .mc-hero{{background:linear-gradient(180deg,rgba(255,255,255,0.18),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px 24px;margin-bottom:18px;}}
10300    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10301    .mc-card{{background:var(--surface);border:1.5px solid var(--oxide);border-radius:14px;padding:16px 18px;flex:1 1 0;min-width:0;min-height:160px;display:flex;flex-direction:column;justify-content:flex-start;transition:box-shadow .15s ease,transform .12s ease;overflow:visible;position:relative;}}
10302    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10303    body.dark-theme .mc-card{{background:var(--surface-2);}}
10304    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10305    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10306    .mc-card-project{{font-size:12px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}}
10307    .mc-card-commit{{display:block;font-family:ui-monospace,monospace;font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:14px;word-break:break-all;}}
10308    .mc-card-commit:hover{{color:var(--oxide);}}
10309    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10310    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10311    .mc-row-label{{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}}
10312    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10313    .mc-card-branch{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent);font-weight:700;display:inline-block;}}
10314    .mc-tag{{font-size:10px;background:rgba(211,122,76,0.12);border:1px solid rgba(211,122,76,0.28);border-radius:4px;padding:1px 6px;color:var(--oxide);font-weight:700;margin-right:3px;display:inline-block;}}
10315    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10316    .mc-scope-tag{{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}}
10317    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10318    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10319    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10320    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10321    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10322    .mc-card-nearest-tip{{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:500;line-height:1.5;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.28);pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.10);}}
10323    .mc-card-nearest-tip::after{{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:rgba(20,12,8,0.97);}}
10324    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10325    .mc-card-code{{font-size:15px;font-weight:800;color:var(--text);margin-top:12px;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:nowrap;}}
10326    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10327    .submod-scope-bar{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1.5px solid var(--line-strong);border-radius:12px;margin:0 0 16px;}}
10328    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10329    .submod-scope-label{{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);flex-shrink:0;white-space:nowrap;}}
10330    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10331    .submod-scope-btn{{padding:5px 13px;border-radius:7px;border:1.5px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;transition:background .12s,border-color .12s,color .12s;}}
10332    .submod-scope-btn:hover{{background:var(--line);}}
10333    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10334    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10335    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px;margin-bottom:18px;position:relative;}}
10336    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10337    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10338    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10339    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10340    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10341    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10342    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10343    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10344    .metrics-table .pos{{color:var(--pos);}}
10345    .metrics-table .neg{{color:var(--neg);}}
10346    .metrics-table .zero{{color:var(--muted);}}
10347    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10348    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10349    .chart-metric-btn{{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
10350    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10351    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10352    .chart-wrap{{width:100%;overflow-x:auto;}}
10353    #mc-chart{{display:block;width:100%;}}
10354    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10355    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10356    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10357    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10358    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10359    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10360    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10361    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10362    .ic-card-h2-row .ic-card-h2{{margin:0;}}
10363    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10364    .ic-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
10365    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10366    .ic-svg-modal-ov{{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
10367    .ic-svg-modal-ov.open{{display:flex;}}
10368    .ic-svg-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:900px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
10369    .ic-svg-modal-hdr{{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}}
10370    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10371    .ic-svg-modal-close{{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}}
10372    .ic-svg-modal-close:hover{{background:var(--line);}}
10373    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10374    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10375    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10376    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10377    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10378    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10379    #mc-ic-tt{{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.5;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.28);max-width:240px;white-space:nowrap;}}
10380    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10381    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10382    .tab-btn{{padding:6px 16px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;transition:background .12s;}}
10383    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10384    .tab-btn:hover:not(.active){{background:var(--line);}}
10385    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10386    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10387    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10388    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10389    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10390    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10391    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10392    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10393    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10394    .table-wrap{{width:100%;overflow-x:auto;}}
10395    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10396    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10397    #file-table th{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);text-align:right;}}
10398    #file-table th.left,#file-table td.left{{text-align:left;}}
10399    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10400    .file-delta-col{{color:var(--muted);font-size:11px;}}
10401    .file-net-col{{font-weight:800;}}
10402    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10403    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10404    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10405    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10406    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10407    .status-badge.modified{{background:#fff2d8;color:#926000;}}
10408    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10409    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10410    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10411    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10412    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10413    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10414    tr.row-added td{{background:rgba(26,143,71,0.04);}}
10415    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10416    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10417    tr.row-unchanged td{{color:var(--muted);}}
10418    tr.row-unchanged .status-badge{{opacity:.65;}}
10419    .file-path{{font-family:ui-monospace,monospace;font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}}
10420    .absent{{color:var(--muted);font-style:italic;}}
10421    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10422    .pagination-info{{font-size:12px;color:var(--muted);}}
10423    .pagination-btns{{display:flex;gap:5px;}}
10424    .pg-btn{{min-width:32px;min-height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
10425    .pg-btn:hover:not(:disabled){{background:var(--line);}}
10426    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10427    .pg-btn:disabled{{opacity:.35;cursor:default;}}
10428    select.per-page{{border:1px solid var(--line-strong);border-radius:7px;background:var(--surface-2);color:var(--text);padding:4px 9px;font-size:12px;cursor:pointer;}}
10429    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s;}}
10430    .export-btn:hover{{background:var(--line);}}
10431    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip{{display:block;}}.status-dot{{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26d768;box-shadow:0 0 0 3px rgba(38,215,104,0.18);flex-shrink:0;}}
10432    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10433    .site-footer a{{color:var(--muted);}}
10434    body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-back,body.pdf-mode .chart-toolbar,body.pdf-mode .filter-tabs-row,body.pdf-mode .filter-tabs,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .submod-scope-bar,body.pdf-mode .settings-modal,body.pdf-mode .site-footer{{display:none!important;}}
10435    body.pdf-mode{{background:#fff!important;}}
10436    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10437    .mc-modal-overlay{{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.52);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;}}
10438    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10439    .mc-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.28);max-width:1000px;width:94%;max-height:86vh;overflow-y:auto;position:relative;}}
10440    .mc-modal-head{{background:var(--nav);color:#fff;padding:16px 20px;border-radius:14px 14px 0 0;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}}
10441    .mc-modal-title{{font-size:18px;font-weight:800;}}
10442    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10443    .mc-modal-close{{background:rgba(255,255,255,0.18);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}}
10444    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10445    .mc-modal-body{{padding:18px 22px;}}
10446    .mc-modal-sec{{margin-bottom:20px;}}
10447    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10448    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10449    .mc-modal-stat{{flex:1 1 0;min-width:0;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:default;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;}}
10450    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10451    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10452    .mc-modal-stat-lbl{{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10453    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10454    .mc-modal-row:last-child{{border-bottom:none;}}
10455    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10456    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10457    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10458    .mc-modal-val a:hover{{text-decoration:underline;}}
10459    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10460    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10461    .mc-modal-stat[data-tip]{{cursor:help;}}
10462    #mc-stat-tt{{display:none;position:fixed;background:rgba(15,10,6,0.96);color:rgba(255,255,255,0.94);border-radius:8px;padding:9px 13px;font-size:12.5px;font-weight:500;line-height:1.5;pointer-events:none;z-index:9001;box-shadow:0 6px 22px rgba(0,0,0,0.34);max-width:300px;border:1px solid rgba(255,255,255,0.12);}}
10463    .mc-card{{cursor:pointer;}}
10464    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10465  </style>
10466</head>
10467<body>
10468  {loading_overlay}
10469  <div class="background-watermarks" aria-hidden="true">
10470    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10471    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10472    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10473    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10474    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10475    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10476  </div>
10477  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10478  <div class="top-nav">
10479    <div class="top-nav-inner">
10480      <a class="brand" href="/">
10481        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10482        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10483      </a>
10484      <div class="nav-right">
10485        <a class="nav-pill" href="/">Home</a>
10486        <div class="nav-dropdown">
10487          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10488          <div class="nav-dropdown-menu">
10489            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
10490          </div>
10491        </div>
10492        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10493        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10494        <div class="nav-dropdown">
10495          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
10496          <div class="nav-dropdown-menu">
10497            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
10498          </div>
10499        </div>
10500        <div class="server-status-wrap" id="server-status-wrap">
10501          <div class="nav-pill server-online-pill" id="server-status-pill">
10502            <span class="status-dot" id="status-dot"></span>
10503            <span id="server-status-label">Server</span>
10504            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10505          </div>
10506          <div class="server-status-tip">
10507            OxideSLOC is running &mdash; accessible on your network.
10508            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10509          </div>
10510        </div>
10511        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10512          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
10513        </button>
10514        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10515          <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
10516          <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
10517        </button>
10518      </div>
10519    </div>
10520  </div>
10521
10522  <div class="page">
10523    <!-- Hero header -->
10524    <div class="mc-hero">
10525      <div class="mc-hero-header">
10526        <div>
10527          <div class="mc-title">Multi-Scan Timeline</div>
10528          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10529          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10530        </div>
10531        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10532          <a class="btn-back" href="/compare-scans"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"></polyline></svg> Compare Scans</a>
10533          <div class="export-group" id="mc-top-export-group">
10534            <button type="button" class="export-btn" id="mc-top-export-html-btn" title="Export this page as a standalone HTML report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export HTML</button>
10535            <button type="button" class="export-btn" id="mc-top-export-pdf-btn" title="Export this page as a PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
10536          </div>
10537        </div>
10538      </div>
10539      {scope_bar_html}
10540      <!-- Scan strip -->
10541      <div class="{mc_strip_class}">{scan_strip}</div>
10542    </div>
10543
10544    <!-- Summary metrics table -->
10545    <div class="panel">
10546      <div class="panel-title">Metric Progression</div>
10547      <div class="table-wrap">
10548        <table class="metrics-table">
10549          <thead>{metrics_thead}</thead>
10550          <tbody>{metrics_tbody}</tbody>
10551        </table>
10552      </div>
10553    </div>
10554
10555    <!-- Scan Charts -->
10556    <div class="panel" id="mc-charts-panel">
10557      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10558      <div class="ic-grid">
10559        <!-- Timeline line chart — spans full width -->
10560        <div class="ic-card" style="grid-column:span 2">
10561          <div class="ic-card-h2-row">
10562            <span class="ic-card-h2">Timeline</span>
10563            <div class="chart-toolbar" style="margin:0">
10564              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10565              <button class="chart-metric-btn" data-metric="files">Files</button>
10566              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10567              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10568              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10569            </div>
10570          </div>
10571          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10572        </div>
10573        <!-- Code Metrics: Scan 1 vs Latest -->
10574        <div class="ic-card">
10575          <div class="ic-chart-hdr"><span class="ic-card-h2">Code Metrics &mdash; Scan 1 vs Latest</span><button class="ic-expand-btn" data-expand-src="mc-ic-c1" data-expand-title="Code Metrics — Scan 1 vs Latest">&#x2922; Full View</button></div>
10576          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded&nbsp;=&nbsp;scan&nbsp;1)</span></div>
10577          <div id="mc-ic-c1"></div>
10578        </div>
10579        <!-- Language Code Delta -->
10580        <div class="ic-card" id="mc-ic-lang-card">
10581          <div class="ic-chart-hdr"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="mc-ic-c3" data-expand-title="Language Code Delta">&#x2922; Full View</button></div>
10582          <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10583          <div id="mc-ic-c3"></div>
10584        </div>
10585        <!-- Delta by Metric -->
10586        <div class="ic-card">
10587          <div class="ic-chart-hdr"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="mc-ic-c2" data-expand-title="Delta by Metric">&#x2922; Full View</button></div>
10588          <div id="mc-ic-c2"></div>
10589        </div>
10590        <!-- File Change Distribution -->
10591        <div class="ic-card">
10592          <div class="ic-chart-hdr"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="mc-ic-c4" data-expand-title="File Change Distribution">&#x2922; Full View</button></div>
10593          <div id="mc-ic-c4"></div>
10594        </div>
10595      </div>
10596    </div>
10597
10598    <!-- File matrix table -->
10599    <div class="panel">
10600      <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
10601      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10602        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10603          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10604          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10605          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10606          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10607          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10608        </div>
10609        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10610          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10611          <div class="export-group">
10612          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10613          <button type="button" class="export-btn" id="export-csv-btn">
10614            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10615            CSV
10616          </button>
10617          <button type="button" class="export-btn" id="mc-file-xls-btn">
10618            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
10619            Excel
10620          </button>
10621          </div>
10622        </div>
10623      </div>
10624      <div class="table-wrap">
10625        <table id="file-table">
10626          <thead>
10627            <tr>
10628              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10629              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10630              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10631              {file_col_headers}
10632              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10633            </tr>
10634          </thead>
10635          <tbody id="file-tbody"></tbody>
10636        </table>
10637      </div>
10638      <div class="pagination">
10639        <span class="pagination-info" id="pg-info"></span>
10640        <div class="pagination-btns" id="pg-btns"></div>
10641        <div style="display:flex;align-items:center;gap:6px;">
10642          <span style="font-size:12px;color:var(--muted)">Show</span>
10643          <select class="per-page" id="per-page-sel">
10644            <option value="25" selected>25 per page</option>
10645            <option value="50">50 per page</option>
10646            <option value="100">100 per page</option>
10647          </select>
10648        </div>
10649      </div>
10650    </div>
10651  </div>
10652
10653  <div id="mc-ic-tt"></div>
10654
10655  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10656    <div class="ic-svg-modal">
10657      <div class="ic-svg-modal-hdr">
10658        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10659        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10660      </div>
10661      <div id="ic-svg-modal-body"></div>
10662    </div>
10663  </div>
10664
10665  <footer class="site-footer">
10666    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10667    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10668    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10669    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10670    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10671  </footer>
10672
10673  <script nonce="{csp_nonce}">
10674  (function(){{
10675    // ── Dark theme ───────────────────────────────────────────────────────────
10676    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10677    var renderInlineCharts=null;
10678    var tt=document.getElementById('theme-toggle');
10679    if(tt)tt.addEventListener('click',function(){{
10680      var on=document.body.classList.toggle('dark-theme');
10681      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10682      renderChart(activeMetric);
10683      if(renderInlineCharts)renderInlineCharts();
10684    }});
10685
10686    // ── Code particles ───────────────────────────────────────────────────────
10687    var container=document.getElementById('code-particles');
10688    if(container){{
10689      var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
10690      for(var i=0;i<28;i++){{
10691        (function(idx){{
10692          var el=document.createElement('span');el.className='code-particle';
10693          el.textContent=snips[idx%snips.length];
10694          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10695          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10696          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10697          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10698          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10699          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10700          container.appendChild(el);
10701        }})(i);
10702      }}
10703    }}
10704
10705    // ── Watermarks ───────────────────────────────────────────────────────────
10706    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10707    if(wms.length){{
10708      var placed=[];
10709      function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
10710      function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
10711      var half=Math.floor(wms.length/2);
10712      wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
10713    }}
10714
10715    // ── Settings / colour scheme modal ───────────────────────────────────────
10716    (function(){{
10717      var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
10718      function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
10719      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10720      function init(){{
10721        var btn=document.getElementById('settings-btn');if(!btn)return;
10722        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10723        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
10724        document.body.appendChild(m);
10725        var g=document.getElementById('scheme-grid');
10726        if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
10727        var cl=document.getElementById('settings-close-btn');
10728        btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
10729        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10730        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10731      }}
10732      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10733    }})();
10734
10735    // ── Timezone support for scan timestamps ─────────────────────────────────
10736    (function(){{
10737      window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};
10738      window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};
10739      window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};
10740      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10741      window.applyTz(storedTz);
10742      function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;window.enhanceTzOptions(tzSel);tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});if(!document.getElementById('tf-select')&&tzSel.parentNode){{var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzSel.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}}}}
10743      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10744    }})();
10745
10746    // ── Data ────────────────────────────────────────────────────────────────
10747    var POINTS={points_json};
10748    var FILES={file_matrix_json};
10749    var N={n};
10750
10751    // ── fmt helper ───────────────────────────────────────────────────────────
10752    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
10753    function fmtFull(n){{return Number(n).toLocaleString();}}
10754    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10755
10756    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10757    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10758    function mcShortRef(p,i){{var c=(p&&p.commit?String(p.commit):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);if(c)return c;var r=(p&&p.run_id?String(p.run_id):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);return r||('scan'+(i+1));}}
10759    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10760    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10761
10762    // ── Timeline chart ───────────────────────────────────────────────────────
10763    var activeMetric='code';
10764    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10765    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10766
10767    function renderChart(metric){{
10768      var svg=document.getElementById('mc-chart');if(!svg)return;
10769      var W=svg.getBoundingClientRect().width||800,H=280;
10770      svg.setAttribute('height',H);
10771      var pad={{l:62,r:20,t:32,b:72}};
10772      var dark=document.body.classList.contains('dark-theme');
10773      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10774      var valid=pts.filter(function(v){{return v!=null;}});
10775      if(!valid.length){{var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(metricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'None of the selected scans reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+escHtml(_nd_msg)+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+escHtml(_nd_sub)+'</text>';return;}}
10776      var minV=0,maxV=Math.max.apply(null,valid);
10777      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10778      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10779      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10780      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10781      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10782      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10783      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10784      var parts=[];
10785      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10786      for(var gi=0;gi<5;gi++){{var gy=pad.t+plotH/4*gi;parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');var gv=maxV-(maxV-minV)/4*gi;parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmt(gv)+'</text>');}}
10787      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10788      var lineD='';var firstPt=true;
10789      for(var i=0;i<N;i++){{if(pts[i]==null)continue;var cx=xOf(i),cy=yOf(pts[i]);areaD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);if(firstPt){{lineD='M '+cx.toFixed(1)+' '+cy.toFixed(1);firstPt=false;}}else{{lineD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);}}}}
10790      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10791      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10792      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10793      for(var i=0;i<N;i++){{
10794        if(pts[i]==null)continue;
10795        var cx=xOf(i),cy=yOf(pts[i]);
10796        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10797        var hasTag=p.tags&&p.tags.length>0;
10798        // Permanent Y-value label above the dot
10799        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtFull(pts[i])+'</text>');
10800        parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
10801        var xanchor=i===0?'start':i===N-1?'end':'middle';
10802        // X-axis label at 2× the original size (18 px)
10803        parts.push('<text x="'+cx.toFixed(1)+'" y="'+(H-pad.b+22)+'" text-anchor="'+xanchor+'" font-size="18" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escHtml(lbl)+'</text>');
10804      }}
10805      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10806      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10807      svg.innerHTML=parts.join('');
10808      svg.addEventListener('click',function(e){{var c=e.target.closest('circle[data-run-id]');if(c)window.location='/runs/html/'+c.getAttribute('data-run-id');}});
10809      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10810      svg.onmousemove=function(e){{
10811        var rect=svg.getBoundingClientRect();
10812        var scaleX=W/rect.width;
10813        var mouseX=(e.clientX-rect.left)*scaleX;
10814        var nearest=-1,minDist=Infinity;
10815        for(var k=0;k<N;k++){{if(pts[k]==null)continue;var dx=Math.abs(xOf(k)-mouseX);if(dx<minDist){{minDist=dx;nearest=k;}}}}
10816        if(nearest<0)return;
10817        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10818        var xhair=svg.querySelector('.mc-xhair');
10819        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10820        xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
10821        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10822        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10823        tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
10824        var bx=rect.left+(nc/W*rect.width)+18;
10825        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10826        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10827      }};
10828      svg.onmouseleave=function(){{
10829        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10830        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10831      }};
10832    }}
10833
10834    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10835
10836    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10837      btn.addEventListener('click',function(){{
10838        activeMetric=this.dataset.metric;
10839        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10840        this.classList.add('active');
10841        renderChart(activeMetric);
10842      }});
10843    }});
10844    if(typeof ResizeObserver!=='undefined'){{
10845      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10846    }}
10847    renderChart(activeMetric);
10848
10849    // ── File matrix table ────────────────────────────────────────────────────
10850    var activeStatus='';
10851    var currentPage=1;
10852    var perPage=25;
10853    var mcSortCol=null,mcSortAsc=true;
10854
10855    function getFiltered(){{
10856      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10857      if(!mcSortCol)return data;
10858      var asc=mcSortAsc;
10859      return data.slice().sort(function(a,b){{
10860        var va,vb;
10861        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10862        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10863        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10864        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10865        else{{return 0;}}
10866        if(asc)return va<vb?-1:va>vb?1:0;
10867        return va<vb?1:va>vb?-1:0;
10868      }});
10869    }}
10870
10871    function renderFilePage(){{
10872      var filtered=getFiltered();
10873      var total=filtered.length;
10874      var totalPages=Math.max(1,Math.ceil(total/perPage));
10875      if(currentPage>totalPages)currentPage=totalPages;
10876      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10877      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10878      var rows=[];
10879      for(var i=start;i<end;i++){{
10880        var f=filtered[i];
10881        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10882        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10883        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10884        for(var j=0;j<N;j++){{
10885          var cv=f.c[j];
10886          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10887          if(j<N-1){{
10888            var dv=f.d[j+1];
10889            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10890              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10891          }}
10892        }}
10893        var tc=f.t;
10894        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10895        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10896      }}
10897      tbody.innerHTML=rows.join('');
10898
10899      var info=document.getElementById('pg-info');
10900      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10901      renderPgBtns(totalPages);
10902    }}
10903
10904    function renderPgBtns(totalPages){{
10905      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10906      var btns=[];
10907      function mkBtn(label,page,active,disabled){{
10908        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10909        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10910      }}
10911      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10912      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10913      if(s>1)btns.push(mkBtn('1',1,false,false));
10914      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10915      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10916      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10917      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10918      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10919      wrap.innerHTML=btns.join('');
10920      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10921        b.addEventListener('click',function(){{
10922          var pg=parseInt(this.dataset.pg,10);
10923          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10924        }});
10925      }});
10926    }}
10927
10928    // Tab filter
10929    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10930      btn.addEventListener('click',function(){{
10931        activeStatus=this.dataset.status||'';
10932        currentPage=1;
10933        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10934        this.classList.add('active');
10935        renderFilePage();
10936      }});
10937    }});
10938
10939    // Per-page selector
10940    var ppSel=document.getElementById('per-page-sel');
10941    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10942
10943    // ── Column header sort ───────────────────────────────────────────────────
10944    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10945      th.addEventListener('click',function(){{
10946        var col=th.dataset.sortCol;
10947        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10948        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10949          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10950        }});
10951        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10952        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10953        currentPage=1;renderFilePage();
10954      }});
10955    }});
10956
10957    // Reset button also clears sort
10958    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10959    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10960      mcSortCol=null;mcSortAsc=true;
10961      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10962        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10963      }});
10964      activeStatus='';currentPage=1;
10965      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10966      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10967      renderFilePage();
10968    }});
10969
10970    renderFilePage();
10971
10972    // ── CSV export ───────────────────────────────────────────────────────────
10973    var exportBtn=document.getElementById('export-csv-btn');
10974    if(exportBtn)exportBtn.addEventListener('click',function(){{
10975      var header=['File','Language','Status'];
10976      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10977      header.push('Net Delta');
10978      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10979      var filtered=getFiltered();
10980      filtered.forEach(function(f){{
10981        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10982        for(var j=0;j<N;j++){{
10983          cols.push(f.c[j]!=null?f.c[j]:'');
10984          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10985        }}
10986        cols.push(f.t);
10987        rows.push(cols.join(','));
10988      }});
10989      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10990      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10991      a.download=mcExportName('csv');a.click();
10992    }});
10993
10994    // ── File matrix extra export buttons ─────────────────────────────────────
10995    (function(){{
10996      var resetBtn=document.getElementById('mc-file-reset-btn');
10997      if(resetBtn)resetBtn.addEventListener('click',function(){{
10998        activeStatus='';currentPage=1;
10999        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11000        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11001        renderFilePage();
11002      }});
11003
11004      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11005      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11006      function mcMakeXlsx(fname){{
11007        var filtered=getFiltered();
11008        var enc=new TextEncoder();
11009        var CT=[];for(var _n=0;_n<256;_n++){{var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}}
11010        function crc32(d){{var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}}
11011        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11012        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11013        var ss=[],si={{}};
11014        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11015        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11016        function WS(){{
11017          var R=0,buf=[];
11018          function cl(c){{return String.fromCharCode(65+c);}}
11019          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11020          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11021          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11022          function xml(cw){{return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/>'+(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}}
11023          return{{sc:sc,nc:nc,row:row,xml:xml}};
11024        }}
11025        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11026        var proj=mcExportProj();
11027        // \u2500\u2500 Summary sheet \u2500\u2500
11028        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11029        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11030        r1(s1(0,proj,2));
11031        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11032        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
11033        r1('');
11034        r1(s1(0,'SCAN SUMMARY',8));
11035        r1(s1(0,'Scan',3)+s1(1,'Commit',3)+s1(2,'Branch',3)+s1(3,'Timestamp',3)+s1(4,'Code Lines',3)+s1(5,'Comment Lines',3)+s1(6,'Files',3)+s1(7,'Tests',3));
11036        POINTS.forEach(function(p,i){{
11037          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11038          r1(s1(0,'Scan '+(i+1))+s1(1,sha||'\u2014')+s1(2,p.branch||'\u2014')+s1(3,p.scanned||'')+n1(4,p.code,4)+n1(5,p.comments,4)+n1(6,p.files,4)+n1(7,p.tests,4));
11039        }});
11040        r1('');
11041        if(POINTS.length>1){{
11042          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11043          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11044          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11045          var nr=function(lbl,a,b){{var d=(+b)-(+a),ds=d>0?'+'+d:String(d);r1(s1(0,lbl)+n1(1,a,4)+n1(2,b,4)+s1(3,ds,dstyle(ds)));}};
11046          nr('Code Lines',pf.code,pl.code);
11047          nr('Comment Lines',pf.comments,pl.comments);
11048          nr('Files Analyzed',pf.files,pl.files);
11049          nr('Tests',pf.tests,pl.tests);
11050          r1('');
11051        }}
11052        var cMod=0,cAdd=0,cRem=0,cUnch=0;
11053        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11054        var totF=FILES.length||1;
11055        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11056        r1(s1(0,'FILE CHANGES',8));
11057        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11058        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11059        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11060        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11061        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11062        r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11063        var lm={{}};
11064        FILES.forEach(function(f){{var l=f.l||'Unknown',d=+f.t||0;if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=d;}});
11065        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11066        if(langs.length){{
11067          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11068          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11069          langs.forEach(function(l){{var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);r1(s1(0,l)+n1(1,e.f,4)+s1(2,dv,dstyle(dv)));}});
11070        }}
11071        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11072        // \u2500\u2500 File Delta sheet \u2500\u2500
11073        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11074        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11075        for(var hi=0;hi<N;hi++){{hcells+=s2(hc++,'Scan '+(hi+1)+' Code',3);if(hi<N-1)hcells+=s2(hc++,'Delta \u2192 '+(hi+2),3);}}
11076        hcells+=s2(hc,'Net Delta',3);
11077        r2(hcells);
11078        filtered.forEach(function(f){{
11079          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11080          for(var j=0;j<N;j++){{cells+=n2(c++,f.c[j]!=null?f.c[j]:'',4);if(j<N-1){{var dv=mcSignDelta(f.d[j+1]);cells+=s2(c++,dv,dstyle(dv));}}}}
11081          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11082          r2(cells);
11083        }});
11084        var ncols=3+N+(N-1)+1;
11085        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11086        var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){{return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}}).join('')+'</sst>';
11087        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11088        var F={{'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
11089          '_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>',
11090          'xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId4" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>',
11091          'xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="16384" windowHeight="8192"/></bookViews><sheets><sheet name="Summary" sheetId="1" r:id="rId1"/><sheet name="File Delta" sheetId="2" r:id="rId2"/></sheets></workbook>',
11092          'xl/styles.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="8"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="14"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font><font><sz val="10"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF155724"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FF721C24"/><name val="Calibri"/></font><font><sz val="11"/><color rgb="FF888888"/><name val="Calibri"/></font><font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFD4EDDA"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF8D7DA"/></patternFill></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="3" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="left"/></xf><xf numFmtId="3" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="4" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="5" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="6" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment horizontal="right"/></xf><xf numFmtId="0" fontId="7" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>',
11093          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11094        var zparts=[],zcds=[],zoff=0,znf=0;
11095        ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'].forEach(function(name){{
11096          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11097          var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);
11098          var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);
11099          var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));
11100          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11101          zoff+=entry.length;znf++;
11102        }});
11103        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11104        var eocd=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
11105        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11106        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11107        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11108        out.set(new Uint8Array(eocd),pos);
11109        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11110        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11111      }}
11112
11113      var xlsBtn=document.getElementById('mc-file-xls-btn');
11114      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11115
11116      // File matrix HTML export — interactive: sort by column, filter by status
11117      function mcFileBuildHtml(){{
11118        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11119        var hdrs=['File','Language','Status'];
11120        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11121        hdrs.push('Net \u0394');
11122        var SI=2;
11123        var allRows=FILES.map(function(f){{var r=[f.p,f.l||'',f.s||''];for(var _i=0;_i<N;_i++){{r.push(f.c[_i]!=null?f.c[_i]:null);if(_i<N-1)r.push(f.d[_i+1]!=null?f.d[_i+1]:null);}}r.push(f.t);return r;}});
11124        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11125        var cnt={{all:allRows.length}};
11126        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11127        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11128        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11129          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11130          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11131          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11132          '.sub{{font-size:12px;color:#99aabb;}}'+
11133          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11134          '.wr{{padding:16px 20px;}}'+
11135          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11136          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11137          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11138          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11139          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11140          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11141          'thead tr{{background:#1a2035;}}'+
11142          'th{{padding:6px 10px;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;white-space:nowrap;cursor:pointer;user-select:none;}}'+
11143          'th:hover{{background:#2a3050;}}'+
11144          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11145          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11146          'tr:nth-child(even) td{{background:#faf7f4;}}'+
11147          'tr:hover td{{background:#f5f0ea;}}'+
11148          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11149          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11150        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11151        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11152          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11153          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11154          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11155          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11156        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11157          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
11158          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
11159          'var n=Number(v);if(ci>SI&&!isNaN(n)&&n!==0){{return n>0?"<span class=\\"ap\\">+"+n.toLocaleString()+"<\\/span>":"<span class=\\"an\\">"+n.toLocaleString()+"<\\/span>";}}'+
11160          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11161          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11162          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
11163          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11164          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11165          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11166          'document.getElementById("tb").innerHTML=data.map(function(r){{return"<tr>"+HDRS.map(function(h,ci){{return"<td>"+fc(r[ci],ci)+"<\\/td>";}}).join("")+"<\\/tr>";}}).join("")'+
11167          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11168          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11169          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11170          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11171          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11172          'sd=(sc===ci)?-sd:1;sc=ci;'+
11173          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11174          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11175          'render();';
11176        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11177          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11178          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
11179          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11180          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11181          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11182          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11183          '<script>'+inlineJs+'<\/script><\/body><\/html>';
11184      }}
11185
11186      var htmlBtn=document.getElementById('mc-file-html-btn');
11187      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11188        var h=mcFileBuildHtml();
11189        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11190        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11191        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11192      }});
11193
11194      var pdfBtn=document.getElementById('mc-file-pdf-btn');
11195      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11196        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11197      }});
11198    }})();
11199
11200    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11201    (function(){{
11202      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11203      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11204      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11205      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11206      function fmt2(n){{return Number(n).toLocaleString();}}
11207      function px(n){{return Math.round(n);}}
11208      var _tt=document.getElementById('mc-ic-tt');
11209      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11210      function addTT(el){{
11211        if(!el)return;
11212        el.addEventListener('mouseover',function(e){{
11213          var t=e.target.closest('[data-ttl]');
11214          if(t&&_tt){{
11215            var ttl=t.getAttribute('data-ttl');
11216            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11217            _tt.style.display='block';mvTT(e);
11218            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11219            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11220          }} else {{
11221            if(_tt)_tt.style.display='none';
11222            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11223          }}
11224        }});
11225        el.addEventListener('mouseleave',function(){{
11226          if(_tt)_tt.style.display='none';
11227          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11228        }});
11229        el.addEventListener('mousemove',function(e){{mvTT(e);}});
11230      }}
11231      function mvTT(e){{if(!_tt)return;var x=e.clientX+16,y=e.clientY-10,r=_tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_tt.style.left=x+'px';_tt.style.top=y+'px';}}
11232      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11233      function buildCharts(){{
11234        if(N<2)return;
11235        var cs=getComputedStyle(document.body);
11236        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11237        var textCol=cv('--text','#43342d');
11238        var mutedCol=cv('--muted','#7b675b');
11239        var gFill=cv('--muted-2','#a08777');
11240        var LGY=cv('--line','#e6d0bf');
11241        var axisCol=cv('--line-strong','#d8bfad');
11242        var surf2col=cv('--surface-2','#f4ede4');
11243        var surfCol=cv('--surface','#fff8f0');
11244        var p0=POINTS[0],pLast=POINTS[N-1];
11245        var dark=document.body.classList.contains('dark-theme');
11246        var FADE=dark?'#524238':'#e6d0bf';
11247        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11248        function niceMax(v){{var x=v||1;var p=Math.pow(10,Math.floor(Math.log10(x)));var n=x/p;var s=n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10;return s*p;}}
11249      var c1mets=[
11250        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11251        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11252        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11253      ];
11254      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11255      // Code Metrics chart — grows to fill the height its grid row settled to (the
11256      // Language Code Delta sibling usually drives that), so it never sits short at
11257      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11258      function drawC1(){{
11259        var C1W=620,C1H=200;
11260        var c1host=document.getElementById('mc-ic-c1');
11261        var c1card=c1host?c1host.closest('.ic-card'):null;
11262        if(c1host&&c1card&&c1host.clientWidth>0){{
11263          var avW=c1host.clientWidth;
11264          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11265          var wantH=availPx*C1W/avW;
11266          if(wantH>C1H)C1H=wantH;
11267        }}
11268        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11269        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11270        for(var gi=1;gi<=4;gi++){{
11271          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11272          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11273          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11274        }}
11275        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11276        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11277        c1mets.forEach(function(m,i){{
11278          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11279          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11280          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11281          c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
11282          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.b)+'</text>';
11283          c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
11284          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.c)+'</text>';
11285          c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Scan 1</text>';
11286          c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Latest</text>';
11287        }});
11288        c1+='</svg>';
11289        return c1;
11290      }}
11291      // Chart 2: Delta by Metric (net delta first scan to last)
11292      var mets=[
11293        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11294        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11295        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11296      ];
11297      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11298      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
11299      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11300      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11301      mets.forEach(function(m,i){{
11302        var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,vcol=(m.v===0?textCol:col),bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt2(m.v);
11303        c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
11304        c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
11305        if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
11306        else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
11307      }});
11308      c2+='</svg>';
11309      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11310      var lm={{}};
11311      FILES.forEach(function(f){{var l=f.l||'Unknown';if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=f.t;}});
11312      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11313      function drawC3(){{
11314        if(!langs.length)return'';
11315        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11316        var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
11317        var c3host=document.getElementById('mc-ic-c3');
11318        var c3card=document.getElementById('mc-ic-lang-card');
11319        var C3H=langs.length*30+24;
11320        if(c3host&&c3card&&c3host.clientWidth>0){{
11321          var avW=c3host.clientWidth;
11322          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11323          var wantH=availPx*C3W/avW;
11324          if(wantH>C3H)C3H=wantH;
11325        }}
11326        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11327        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11328        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11329        langs.forEach(function(l,i){{
11330          var e=lm[l],yc=topPad+band*(i+0.5),bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
11331          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11332          c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+px(yc-barH/2)+'" width="'+px(bw)+'" height="'+px(barH)+'" fill="'+col+'" rx="3"/>';
11333          if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+px(yc+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
11334          else{{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+px(yc+4)+'" text-anchor="'+anc3+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
11335          c3+='<text x="'+(C3W-5)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
11336        }});
11337        c3+='</svg>';
11338        return c3;
11339      }}
11340      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11341      var fm=0,fa=0,fr=0,fu=0;
11342      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11343      var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:FADE}}].filter(function(s){{return s.v>0;}});
11344      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11345      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11346      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11347      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
11348      if(segs.length===1){{
11349        c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
11350        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11351        c4+='<text x="'+cx4+'" y="'+px(cy4-(Ro+Ri)/2+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+pctFill(segs[0].c)+'">100%</text>';
11352      }} else {{
11353        segs.forEach(function(s){{
11354          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11355          var x1=cx4+Ro*Math.cos(ang4),y1=cy4+Ro*Math.sin(ang4),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
11356          var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang4),yi2=cy4+Ri*Math.sin(ang4);
11357          c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+s.c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
11358          if(sw>0.32){{var midA=ang4+sw/2,rr=(Ro+Ri)/2,lx=cx4+rr*Math.cos(midA),ly=cy4+rr*Math.sin(midA);c4+='<text x="'+px(lx)+'" y="'+px(ly+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="700" fill="'+pctFill(s.c)+'">'+px(s.v/tot4*100)+'%</text>';}}
11359          ang4+=sw;
11360        }});
11361      }}
11362      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11363      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11364      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11365      segs.forEach(function(s,i){{
11366        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11367        c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
11368        c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
11369        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11370      }});
11371      c4+='</svg>';
11372      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11373      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11374      // once at natural height to seed the row, then both are filled to the row the
11375      // grid settled to, so neither sits short at the top of an over-tall cell.
11376      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11377      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11378      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11379      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11380      var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
11381      if(e1)e1.innerHTML=drawC1();
11382      }}
11383      buildCharts();
11384      renderInlineCharts=buildCharts;
11385      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11386      (function(){{
11387        var ov=document.getElementById('ic-svg-modal-ov');
11388        var body=document.getElementById('ic-svg-modal-body');
11389        var ttl=document.getElementById('ic-svg-modal-title');
11390        var closeBtn=document.getElementById('ic-svg-modal-close');
11391        if(!ov||!body)return;
11392        function close(){{ov.classList.remove('open');body.innerHTML='';}}
11393        function open(srcId,title){{
11394          var src=document.getElementById(srcId);if(!src)return;
11395          ttl.textContent=title||'';
11396          var card=src.closest('.ic-card');
11397          var legHtml='';
11398          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11399          body.innerHTML=legHtml+src.innerHTML;
11400          var svg=body.querySelector('svg');
11401          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11402          addTT(body);
11403          ov.classList.add('open');
11404        }}
11405        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11406          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11407        }});
11408        if(closeBtn)closeBtn.addEventListener('click',close);
11409        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11410        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11411      }})();
11412
11413      // HTML legend hover → highlight matching SVG bars within the SAME card only
11414      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11415        var metric=leg.getAttribute('data-highlight');
11416        var parentCard=leg.closest('.ic-card');
11417        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11418        if(!chartEl)return;
11419        leg.addEventListener('mouseenter',function(){{
11420          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11421            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11422              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11423              x.style.opacity='1';
11424            }} else {{
11425              x.style.opacity='0.28';
11426            }}
11427          }});
11428        }});
11429        leg.addEventListener('mouseleave',function(){{
11430          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11431        }});
11432      }});
11433      // Author handles
11434      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11435
11436      // ── Export helpers ────────────────────────────────────────────────────────
11437      // Fetch one image from the server and return a data-URI Promise
11438      function mcFetchUri(path){{
11439        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11440          return new Promise(function(res){{
11441            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11442          }});
11443        }}).catch(function(){{return '';}});
11444      }}
11445      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11446      function mcInlineImgs(html,cb){{
11447        var paths=[],seen={{}};
11448        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11449        if(!paths.length){{cb(html);return;}}
11450        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11451          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11452          .catch(function(){{cb(html);}});
11453      }}
11454      // Capture full-page HTML with all table rows visible
11455      function mcRawHtml(pdfMode){{
11456        if(pdfMode)document.body.classList.add('pdf-mode');
11457        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11458        var html=document.documentElement.outerHTML;
11459        perPage=s;currentPage=p;renderFilePage();
11460        if(pdfMode)document.body.classList.remove('pdf-mode');
11461        return html;
11462      }}
11463
11464      // HTML export (full page with inlined images)
11465      function mcDoHtml(btn,fname){{
11466        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11467        mcInlineImgs(mcRawHtml(false),function(html){{
11468          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11469          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11470          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11471          btn.disabled=false;btn.innerHTML=orig;
11472        }});
11473      }}
11474      // PDF export — comprehensive document-style report: full numbers, all sections
11475      function mcBuildPdfHtml(){{
11476        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11477        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11478        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11479        function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
11480        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11481        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11482        function ptRef(pt,i){{return pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,7):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));}}
11483        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11484        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11485        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11486        // Header/footer flow in document order (NOT position:fixed) — a fixed
11487        // header repeats every printed page in Chromium and overlaps the content
11488        // below it, swallowing the first rows of pages 2+ and clipping the cards
11489        // on page 1. The table <thead> repeats per page natively, so every row
11490        // stays visible.
11491        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11492          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11493          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11494          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11495          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11496          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11497          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11498          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11499          '.info-bar{{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11500          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11501          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11502          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11503          '.body{{padding:12px 18px 0;}}'+
11504          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11505          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11506          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11507          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11508          '.sec{{margin-bottom:10px;}}'+
11509          '.sh{{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11510          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11511          'th{{background:#1a2035;color:#fff;padding:4px 7px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11512          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11513          'tr:nth-child(even) td{{background:#faf8f6;}}';
11514        // ── Metric Progression ────────────────────────────────────────────────
11515        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11516        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11517        var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
11518        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11519        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11520        var progRows=POINTS.map(function(pt,i){{
11521          var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,8):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));
11522          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11523            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11524            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11525            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11526            '<td style="text-align:right">'+full(pt.files)+'</td>';
11527          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11528          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11529          return r+'</tr>';
11530        }}).join('');
11531        // ── Scan-to-scan changes ──────────────────────────────────────────────
11532        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11533          var prev=POINTS[i];
11534          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11535          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11536          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11537            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11538            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11539            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11540            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11541        }}).join(''):'';
11542        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11543        var fmSection='';
11544        if(FILES&&FILES.length){{
11545          // Hard cap on per-scan columns so the table never overflows the page width.
11546          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11547          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11548          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11549          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11550          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11551          var fmRows=topFiles.map(function(f){{
11552            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11553            var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'&mdash;')+'</td>';
11554            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11555            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11556            return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
11557          }}).join('');
11558          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11559          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11560            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11561        }}
11562        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11563          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11564          '<div class="pdf-header"><div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Multi-Scan Timeline</div><div class="ph-date">'+esc(now)+'</div></div><div class="info-bar"><div><div class="ib-name">{project_label}</div></div><div class="ib-right">{n} scans compared<br>'+commitsList+'</div></div></div>'+
11565
11566          '<div class="body">'+
11567          '<div class="sg">'+
11568          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11569            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11570          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11571            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11572          (codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
11573            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11574          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11575          '</div>'+
11576          '<div class="sec"><p class="sh">Metric Progression</p>'+
11577          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11578          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11579          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11580          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11581          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11582          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11583          fmSection+
11584          '</div>'+
11585          '<div class="pdf-footer"><div class="ftr"><span>oxide-sloc v{version} | AGPL-3.0-or-later</span><span>Multi-Scan Timeline Report</span><span>{project_label} &middot; {n} scans</span></div></div>'+
11586          '</body></html>';
11587      }}
11588      function mcDoPdf(btn){{
11589        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11590      }}
11591
11592      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11593      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11594      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11595      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11596      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11597      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11598      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11599      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11600      if(location.protocol==='file:'){{
11601        [mcHtmlBtn,mcTopHtmlBtn,document.getElementById('mc-file-html-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='Already viewing an exported HTML file';b.textContent='Export HTML';}}}} );
11602        [mcPdfBtn,mcTopPdfBtn,document.getElementById('mc-file-pdf-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='PDF export requires a running server';b.textContent='Export PDF';}}}} );
11603      }}
11604    }})();
11605    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11606    (function(){{
11607      function $(id){{return document.getElementById(id);}}
11608      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11609      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11610      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11611      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11612      function openModal(idx){{
11613        var ov=$('mc-modal-overlay');if(!ov)return;
11614        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11615        if(idx<0||idx>=N)return;
11616        var pt=POINTS[idx];
11617        titleEl.textContent='Scan '+(idx+1);
11618        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11619        subEl.textContent=lbl;
11620        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11621          '<div class="mc-modal-stat" data-tip="Physical lines of source code that are neither blank nor comment-only. This is the primary SLOC metric used to size the codebase."><div class="mc-modal-stat-val">'+full(pt.code)+'</div><div class="mc-modal-stat-lbl">Code Lines</div></div>'+
11622          '<div class="mc-modal-stat" data-tip="Lines made up of code comments (single-line or block). Documentation within the source that is not executed."><div class="mc-modal-stat-val">'+full(pt.comments)+'</div><div class="mc-modal-stat-lbl">Comments</div></div>'+
11623          '<div class="mc-modal-stat" data-tip="Empty lines or lines containing only whitespace. Counted separately from code and comment lines."><div class="mc-modal-stat-val">'+full(pt.blank)+'</div><div class="mc-modal-stat-lbl">Blank Lines</div></div>'+
11624          '<div class="mc-modal-stat" data-tip="Total number of source files analyzed in this scan across every supported language."><div class="mc-modal-stat-val">'+full(pt.files)+'</div><div class="mc-modal-stat-lbl">Files</div></div>'+
11625          (pt.tests!=null&&Number(pt.tests)>0?'<div class="mc-modal-stat" data-tip="Number of unit-test definitions detected across the scanned files."><div class="mc-modal-stat-val">'+full(pt.tests)+'</div><div class="mc-modal-stat-lbl">Tests</div></div>':'')+
11626          (pt.cov!=null?'<div class="mc-modal-stat" data-tip="Percentage of code lines covered by tests for this scan, shown when coverage results were captured."><div class="mc-modal-stat-val">'+Number(pt.cov).toFixed(1)+'%</div><div class="mc-modal-stat-lbl">Coverage</div></div>':'')+
11627          '</div></div>';
11628        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11629          (pt.commit?'<div class="mc-modal-row"><span class="mc-modal-key">Commit</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.commit)+'</a></span></div>':'')+
11630          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11631          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11632          (pt.nearest?'<div class="mc-modal-row"><span class="mc-modal-key">Nearest tag</span><span class="mc-modal-val">'+esc(pt.nearest)+'</span></div>':'')+
11633          (pt.commit_date?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit on</span><span class="mc-modal-val">'+esc(pt.commit_date)+'</span></div>':'')+
11634          (pt.author?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit by</span><span class="mc-modal-val">'+esc(pt.author)+'</span></div>':'')+
11635          (pt.scanned?'<div class="mc-modal-row"><span class="mc-modal-key">Scanned on</span><span class="mc-modal-val">'+esc(pt.scanned)+'</span></div>':'')+
11636          '<div class="mc-modal-row"><span class="mc-modal-key">Run ID</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.run_id)+'</a></span></div>'+
11637          '</div>';
11638        var dHtml='';
11639        if(idx>0){{
11640          var prev=POINTS[idx-1];
11641          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11642          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11643            '<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
11644            '<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
11645            '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
11646            '</div></div>';
11647        }}
11648        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11649        ov.classList.add('open');document.body.style.overflow='hidden';
11650      }}
11651      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11652      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11653      document.addEventListener('click',function(e){{
11654        if(!e.target||!e.target.closest)return;
11655        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11656        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11657        var card=e.target.closest('.mc-card');
11658        if(!card)return;
11659        if(e.target.closest('a'))return;
11660        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11661        var i=cards.indexOf(card);
11662        if(i>=0)openModal(i);
11663      }});
11664      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11665      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11666      var statTip=null;
11667      document.addEventListener('mousemove',function(e){{
11668        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11669        if(!box){{if(statTip)statTip.style.display='none';return;}}
11670        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11671        var tip=box.getAttribute('data-tip')||'';
11672        if(statTip.textContent!==tip)statTip.textContent=tip;
11673        statTip.style.display='block';
11674        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11675        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11676        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11677        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11678      }});
11679      (function tagCards(){{var cs=document.querySelectorAll('.mc-card');for(var k=0;k<cs.length;k++)cs[k].setAttribute('title','Click to view full scan details');}})();
11680    }})();
11681  }})();
11682  </script>
11683  <script nonce="{csp_nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
11684  if(location.protocol==='file:'){{if(lbl)lbl.textContent='Offline';if(dot){{dot.style.background='#888';dot.style.boxShadow='none';}}if(pingEl)pingEl.textContent='';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}}
11685  if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
11686  <!-- Scan card detail modal -->
11687  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11688    <div class="mc-modal" id="mc-modal">
11689      <div class="mc-modal-head">
11690        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11691        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11692      </div>
11693      <div class="mc-modal-body" id="mc-modal-body"></div>
11694    </div>
11695  </div>
11696  {toast_assets}
11697</body>
11698</html>"#,
11699        project_label = html_escape(project_label),
11700        n = n,
11701        scan_strip = scan_strip,
11702        mc_strip_class = mc_strip_class,
11703        metrics_thead = metrics_thead,
11704        metrics_tbody = metrics_tbody,
11705        file_col_headers = file_col_headers,
11706        total_files = total_files,
11707        files_modified = files_modified,
11708        files_added = files_added,
11709        files_removed = files_removed,
11710        files_unchanged = files_unchanged,
11711        points_json = points_json,
11712        file_matrix_json = file_matrix_json,
11713        nav_compare_active = nav_compare_active,
11714        version = version,
11715        csp_nonce = csp_nonce,
11716        scope_bar_html = scope_bar_html,
11717        scope_label = scope_label,
11718        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11719    )
11720}
11721
11722// ── Trend report page ─────────────────────────────────────────────────────────
11723// Protected. Interactive time-series chart page that loads scan history via
11724// /api/metrics/history and renders a vanilla-SVG line chart.
11725//
11726// GET /trend-reports
11727
11728#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11729async fn trend_report_handler(
11730    State(state): State<AppState>,
11731    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11732) -> Response {
11733    auto_scan_watched_dirs(&state).await;
11734
11735    let watched_dirs_list: Vec<String> = {
11736        let wd = state.watched_dirs.lock().await;
11737        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11738    };
11739
11740    // Collect distinct project roots for the root selector dropdown.
11741    let roots: Vec<String> = {
11742        let reg = state.registry.lock().await;
11743        let mut seen = std::collections::BTreeSet::new();
11744        reg.entries
11745            .iter()
11746            .flat_map(|e| e.input_roots.iter().cloned())
11747            .filter(|r| seen.insert(r.clone()))
11748            .collect()
11749    };
11750
11751    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11752    let nonce = &csp_nonce;
11753    let version = env!("CARGO_PKG_VERSION");
11754    let toast_assets = sloc_toast_assets(nonce);
11755
11756    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11757    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11758    // of interactive controls — folder watching is managed by the host administrator.
11759    let watched_dirs_html: String = if state.server_mode {
11760        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
11761    } else {
11762        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11763            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11764                .to_string()
11765        } else {
11766            watched_dirs_list
11767                .iter()
11768                .fold(String::new(), |mut s, d| {
11769                    use std::fmt::Write as _;
11770                    let escaped =
11771                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11772                    write!(
11773                        s,
11774                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
11775                    ).expect("write to String is infallible");
11776                    s
11777                })
11778        };
11779        format!(
11780            r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
11781        )
11782    };
11783
11784    let html = format!(
11785        r##"<!doctype html>
11786<html lang="en">
11787<head>
11788  <meta charset="utf-8" />
11789  <meta name="viewport" content="width=device-width, initial-scale=1" />
11790  <title>OxideSLOC | Trend Reports</title>
11791  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11792  <style nonce="{nonce}">
11793    :root {{
11794      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11795      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11796      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11797      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11798      --info-bg:#eef3ff; --info-text:#4467d8;
11799    }}
11800    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11801    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
11802    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11803    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11804    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
11805    @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
11806    .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
11807    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11808    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
11809    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11810    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
11811    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11812    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11813    @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
11814    .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
11815    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11816    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11817    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11818    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11819    .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
11820    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
11821    .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
11822    .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
11823    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11824    .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
11825    .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
11826    .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
11827    .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
11828    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11829    .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
11830    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
11831    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
11832    .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
11833    .tz-select:focus{{border-color:var(--oxide);}}
11834    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11835    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11836    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11837    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11838    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11839    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11840    .trend-title-block{{flex:1;min-width:0;}}
11841    .controls-centered{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
11842    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11843    .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
11844    .chart-select:focus{{border-color:var(--accent);}}
11845    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11846    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11847    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
11848    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11849    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11850    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11851    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
11852    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11853    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11854    .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
11855    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11856    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11857    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11858    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11859    .tr-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;white-space:nowrap;}}
11860    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11861    .tr-chart-full-modal{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
11862    .tr-chart-full-inner{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1600px;width:100%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
11863    .chart-hint-inline{{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);font-weight:600;white-space:nowrap;margin-top:8px;}}
11864    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11865    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11866    .chart-section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
11867    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11868    .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;user-select:none;}}
11869    .data-table td{{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
11870    .data-table tr:last-child td{{border-bottom:none;}}
11871    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11872    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11873    .table-wrap{{width:100%;overflow-x:auto;}}
11874    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11875    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11876    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11877    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11878    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11879    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11880    .filter-input{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:text;min-width:180px;}}
11881    .filter-select{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:pointer;}}
11882    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11883    .pagination-info{{font-size:13px;color:var(--muted);}}
11884    .pagination-btns{{display:flex;gap:6px;}}
11885    .pg-btn{{min-width:34px;min-height:34px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;}}
11886    .pg-btn:hover{{background:var(--line);}} .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}} .pg-btn:disabled{{opacity:.35;cursor:default;pointer-events:none;}}
11887    #scan-history-table col:nth-child(1){{width:155px;}}
11888    #scan-history-table col:nth-child(2){{width:240px;}}
11889    #scan-history-table col:nth-child(3){{width:82px;}}
11890    #scan-history-table col:nth-child(4){{width:82px;}}
11891    #scan-history-table col:nth-child(5){{width:90px;}}
11892    #scan-history-table col:nth-child(6){{width:90px;}}
11893    #scan-history-table col:nth-child(7){{width:88px;}}
11894    #scan-history-table col:nth-child(8){{width:150px;}}
11895    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11896    .tag-chip{{display:inline-flex;padding:2px 8px;border-radius:999px;background:var(--info-bg);color:var(--info-text);font-size:11px;font-weight:700;margin-right:4px;}}
11897    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
11898    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11899    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11900    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11901    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11902    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11903    .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
11904    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11905    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11906    .watched-chip-rm:hover{{color:var(--oxide);}}
11907    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11908    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11909    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11910    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11911    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11912    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11913    a.run-link:hover{{text-decoration:underline;}}
11914    .run-id-chip{{font-family:ui-monospace,monospace;font-size:11px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;color:var(--muted);}}
11915    .git-chip{{font-family:ui-monospace,monospace;font-size:11px;background:rgba(100,130,220,0.08);border:1px solid rgba(100,130,220,0.20);border-radius:6px;padding:2px 7px;color:var(--accent-2);}}
11916    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11917    .metric-num{{font-weight:700;color:var(--text);}}
11918    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11919    .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}}
11920    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11921    .btn.primary:hover{{opacity:.9;}}
11922    .rpt-btn{{min-width:58px;justify-content:center;}}
11923    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11924    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11925    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11926    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11927    .submod-details summary::-webkit-details-marker{{display:none;}}
11928    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11929    .submod-view-btn{{display:inline-flex;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.22);color:var(--accent-2);text-decoration:none;white-space:nowrap;}}
11930    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11931    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11932    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11933    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
11934    .export-btn:hover{{background:var(--line);}}
11935    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11936    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11937    .site-footer a{{color:var(--muted);}}
11938    .loading-state{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:52px 24px;gap:14px;color:var(--muted);font-size:13px;font-weight:600;}}
11939    .loading-spinner{{width:30px;height:30px;border:3px solid var(--line);border-top-color:var(--oxide);border-radius:50%;animation:spin-load 0.75s linear infinite;}}
11940    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11941    /* Modal system (Retention Policy / Clean-up) */
11942    .tr-modal-backdrop{{display:none;position:fixed;inset:0;z-index:9000;background:rgba(40,24,12,0.34);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);align-items:center;justify-content:center;padding:24px;animation:tr-fade .16s ease;}}
11943    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11944    .tr-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 28px 70px rgba(40,24,12,0.32),0 4px 14px rgba(40,24,12,0.16);width:100%;max-height:92vh;overflow-y:auto;animation:tr-pop .18s cubic-bezier(.2,.9,.3,1.2);}}
11945    .tr-modal{{background:rgba(255,255,255,0.90);}}
11946    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11947    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11948    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11949    .tr-modal-icon{{flex:none;width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 4px 12px rgba(184,80,40,0.32);}}
11950    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11951    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11952    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11953    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11954    .tr-modal-body{{padding:22px 30px;}}
11955    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11956    .tr-btn{{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:10px;font-size:13.5px;font-weight:800;cursor:pointer;border:1px solid transparent;transition:transform .12s ease,box-shadow .12s ease,background .12s ease,opacity .12s ease;font-family:inherit;line-height:1;}}
11957    .tr-btn:hover{{transform:translateY(-1px);}}
11958    .tr-btn:active{{transform:translateY(0);}}
11959    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11960    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11961    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11962    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11963    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11964    .tr-btn-secondary:hover{{background:var(--line);}}
11965    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11966    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11967  </style>
11968</head>
11969<body>
11970  <div class="background-watermarks" aria-hidden="true">
11971    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11972    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11973    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11974    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11975    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11976    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11977  </div>
11978  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11979  <div class="top-nav">
11980    <div class="top-nav-inner">
11981      <a class="brand" href="/">
11982        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11983        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11984      </a>
11985      <div class="nav-right">
11986        <a class="nav-pill" href="/">Home</a>
11987        <div class="nav-dropdown">
11988          <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11989          <div class="nav-dropdown-menu">
11990            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
11991          </div>
11992        </div>
11993        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11994        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11995        <div class="nav-dropdown">
11996          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
11997          <div class="nav-dropdown-menu">
11998            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
11999          </div>
12000        </div>
12001        <div class="server-status-wrap" id="server-status-wrap">
12002          <div class="nav-pill server-online-pill" id="server-status-pill">
12003            <span class="status-dot" id="status-dot"></span>
12004            <span id="server-status-label">Server</span>
12005            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12006          </div>
12007          <div class="server-status-tip">
12008            OxideSLOC is running — accessible on your network.
12009            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12010          </div>
12011        </div>
12012        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12013          <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
12014        </button>
12015        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12016          <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
12017          <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
12018        </button>
12019      </div>
12020    </div>
12021  </div>
12022
12023  <div class="page">
12024    {watched_dirs_html}
12025    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12026      <div class="scan-overlay-card">
12027        <div class="scan-spinner"></div>
12028        <div class="scan-overlay-text">Scanning folder…</div>
12029        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12030      </div>
12031    </div>
12032    <style>
12033    .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
12034    .scan-overlay.active{{display:flex;}}
12035    .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
12036    .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
12037    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12038    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12039    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12040    </style>
12041    <div class="summary-strip" id="trend-stats"></div>
12042    <div class="panel">
12043      <div class="trend-header">
12044        <div class="trend-title-block">
12045          <h1>Trend Reports</h1>
12046          <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root,<br>choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
12047          <span class="chart-hint-inline">
12048            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
12049            Click a dot or row to view its full report &nbsp;·&nbsp; <span class="dot" style="background:#C45C10;"></span>&thinsp;regular scan &nbsp;<span class="dot" style="background:#4472C4;"></span>&thinsp;tagged / release scan
12050          </span>
12051        </div>
12052        <div class="chart-actions">
12053          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12054            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12055            Retention Policy
12056          </button>
12057          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12058            <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
12059            Clean up old runs
12060          </button>
12061          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12062            <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
12063            Export Excel
12064          </button>
12065          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12066            <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
12067            Export PNG
12068          </button>
12069          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12070            <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
12071            Export PDF
12072          </button>
12073        </div>
12074      </div>
12075
12076      <div class="controls-centered">
12077        <label>Project Root:
12078          <select class="chart-select" id="root-sel">
12079            <option value="">All projects</option>
12080          </select>
12081        </label>
12082        <label>Y Metric:
12083          <select class="chart-select" id="y-sel">
12084            <option value="code_lines">Code Lines</option>
12085            <option value="comment_lines">Comment Lines</option>
12086            <option value="blank_lines">Blank Lines</option>
12087            <option value="physical_lines">Physical Lines</option>
12088            <option value="files_analyzed">Files Analyzed</option>
12089          </select>
12090        </label>
12091        <label>X Axis:
12092          <select class="chart-select" id="x-sel">
12093            <option value="time">By Time</option>
12094            <option value="commit" selected>By Commit</option>
12095            <option value="release">By Release</option>
12096            <option value="tag">Tagged Commits</option>
12097          </select>
12098        </label>
12099        <label id="submodule-label" style="display:none;">Submodule:
12100          <select class="chart-select" id="sub-sel">
12101            <option value="">All (project total)</option>
12102          </select>
12103        </label>
12104        <label>Chart Size:
12105          <select class="chart-select" id="scale-sel">
12106            <option value="0.75">Compact</option>
12107            <option value="1.2" selected>Normal</option>
12108            <option value="1.38">Large</option>
12109          </select>
12110        </label>
12111        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
12112      </div>
12113
12114      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12115      <div id="data-table-wrap" style="overflow-x:auto;"></div>
12116    </div>
12117  </div>
12118
12119  <script nonce="{nonce}">
12120    (function() {{
12121      // Theme persistence
12122      var b = document.body;
12123      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12124      var tgl = document.getElementById('theme-toggle');
12125      if (tgl) tgl.addEventListener('click', function() {{
12126        var d = b.classList.toggle('dark-theme');
12127        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12128      }});
12129
12130      // Watermark randomizer
12131      (function() {{
12132        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12133        if (!wms.length) return;
12134        var placed = [];
12135        function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
12136        function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
12137        var half=Math.floor(wms.length/2);
12138        wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
12139      }})();
12140
12141      // Code particles
12142      (function() {{
12143        var container = document.getElementById('code-particles');
12144        if (!container) return;
12145        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','.rs .go .py','sloc_core','render_html','2,163 code'];
12146        for (var i = 0; i < 38; i++) {{
12147          (function(idx) {{
12148            var el = document.createElement('span');
12149            el.className = 'code-particle';
12150            el.textContent = snippets[idx % snippets.length];
12151            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12152            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12153            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12154            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
12155            container.appendChild(el);
12156          }})(i);
12157        }}
12158      }})();
12159
12160      // Watched folder picker
12161      (function(){{
12162        window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
12163        document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
12164      }})();
12165      (function() {{
12166        var btn = document.getElementById('add-watched-btn');
12167        if (!btn) return;
12168        btn.addEventListener('click', function() {{
12169          fetch('/pick-directory?kind=reports')
12170            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12171            .then(function(data) {{
12172              if (!data.cancelled && data.selected_path) {{
12173                var form = document.createElement('form');
12174                form.method = 'POST';
12175                form.action = '/watched-dirs/add';
12176                var ri = document.createElement('input');
12177                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12178                var fi = document.createElement('input');
12179                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12180                form.appendChild(ri); form.appendChild(fi);
12181                document.body.appendChild(form);
12182                if (window.__scanOverlay) window.__scanOverlay();
12183                form.submit();
12184              }}
12185            }})
12186            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12187        }});
12188      }})();
12189
12190      // Settings / color-scheme modal
12191      (function() {{
12192        var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
12193        function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
12194        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12195        var btn=document.getElementById('settings-btn');if(!btn)return;
12196        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12197        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
12198        document.body.appendChild(m);
12199        var g=document.getElementById('scheme-grid');
12200        if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
12201        var cl=document.getElementById('settings-close');
12202        window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}if(tzSel){{tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}window.applyTz(storedTz);(function(){{var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}})();
12203        btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
12204        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12205        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12206      }})();
12207    }})();
12208
12209    var ROOTS = {roots_json};
12210    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12211    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12212    var allData = [];
12213
12214    // Populate root selector
12215    var rootSel = document.getElementById('root-sel');
12216    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12217
12218    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
12219    function fmtFull(n){{return Number(n).toLocaleString();}}
12220    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
12221
12222    // Tooltip
12223    var tt = document.createElement('div');
12224    tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:100000;max-width:280px;color:var(--text);';
12225    document.body.appendChild(tt);
12226    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12227    function moveTT(e){{var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+'px';tt.style.top=y+'px';}}
12228    function hideTT(){{tt.style.display='none';}}
12229    window.addEventListener('blur',function(){{hideTT();}});
12230    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12231
12232    function statExact(compact, full){{
12233      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12234    }}
12235    function statVal(n){{
12236      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12237    }}
12238
12239    function updateStats(data){{
12240      var statsEl=document.getElementById('trend-stats');
12241      if(!statsEl)return;
12242      if(!data||!data.length){{statsEl.innerHTML='';return;}}
12243      var yKey=document.getElementById('y-sel').value;
12244      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12245      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12246      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12247      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12248      var absDelta=Math.abs(delta);
12249      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12250      var deltaExact=statExact(deltaCompact,deltaFull);
12251      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12252      statsEl.innerHTML=
12253        '<div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">'+data.length+'</div><div class="stat-chip-label">Total Scans</div></div>'+
12254        '<div class="stat-chip"><div class="stat-chip-tip">The most recent recorded value for the selected metric</div><div class="stat-chip-val">'+statVal(lastVal)+'</div><div class="stat-chip-label">Latest '+(Y_LABELS[yKey]||yKey)+'</div></div>'+
12255        '<div class="stat-chip"><div class="stat-chip-tip">Change in the selected metric from the earliest to the latest scan</div><div class="stat-chip-val '+cls+'">'+sign+deltaCompact+deltaExact+'</div><div class="stat-chip-label">Net Change</div></div>'+
12256        '<div class="stat-chip"><div class="stat-chip-tip">Number of distinct project roots tracked across all scans</div><div class="stat-chip-val">'+Object.keys(projs).length+'</div><div class="stat-chip-label">Projects</div></div>';
12257    }}
12258
12259    var subSel = document.getElementById('sub-sel');
12260    var subLabel = document.getElementById('submodule-label');
12261
12262    function populateSubmodules(root){{
12263      if(!subSel||!subLabel)return;
12264      while(subSel.options.length>1)subSel.remove(1);
12265      subSel.value='';
12266      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12267      fetch(url)
12268        .then(function(r){{return r.json();}})
12269        .then(function(subs){{
12270          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12271          subs.forEach(function(s){{
12272            var o=document.createElement('option');
12273            o.value=s.name;
12274            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12275            subSel.appendChild(o);
12276          }});
12277          subLabel.style.display='';
12278        }})
12279        .catch(function(){{subLabel.style.display='none';}});
12280    }}
12281
12282    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12283
12284    function loadAndRender(){{
12285      var root = rootSel.value;
12286      var sub = subSel ? subSel.value : '';
12287      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12288      document.getElementById('data-table-wrap').innerHTML='';
12289      var url = '/api/metrics/history?limit=100'
12290        + (root ? '&root='+encodeURIComponent(root) : '')
12291        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
12292      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12293        allData = data;
12294        render(data);
12295        updateStats(data);
12296      }}).catch(function(){{
12297        document.getElementById('chart-wrap').innerHTML='<div class="empty-state">Failed to load scan history. Make sure the server is running and has recorded at least one scan.</div>';
12298      }});
12299    }}
12300
12301    function render(data){{
12302      var yKey = document.getElementById('y-sel').value;
12303      var xMode = document.getElementById('x-sel').value;
12304
12305      // Filter for tag/release mode
12306      var pts = data;
12307      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12308
12309      // Sort oldest-first for the line chart
12310      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12311
12312      var wrap = document.getElementById('chart-wrap');
12313      if(!pts.length){{
12314        var emptyMsg = (xMode === 'tag')
12315          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12316          : 'No scan data found for the selected filters.';
12317        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12318        renderTable([]);
12319        return;
12320      }}
12321
12322      var scaleEl=document.getElementById('scale-sel');
12323      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12324      renderTrendInto(wrap, pts, yKey, xMode, sc);
12325      renderTable(pts, yKey);
12326    }}
12327
12328    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12329    // Shared by the inline chart and the Full View modal so both render identically.
12330    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12331      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12332      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12333      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12334      var W=Math.max(600,availW),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
12335      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12336
12337      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12338
12339      var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
12340      svg+='<defs><linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#C45C10" stop-opacity="0.18"/><stop offset="100%" stop-color="#C45C10" stop-opacity="0"/></linearGradient></defs>';
12341
12342      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12343
12344      // Grid + Y axis ticks
12345      for(var ti=0;ti<=5;ti++){{
12346        var gy=PT+CH-Math.round(ti/5*CH);
12347        var gv=Math.round(ti/5*maxY);
12348        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12349        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12350      }}
12351
12352      // X axis labels (every N-th point to avoid crowding)
12353      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12354      pts.forEach(function(d,i){{
12355        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12356        if(i%labelEvery===0||i===pts.length-1){{
12357          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):(xMode==='release'?(d.nearest_tag||d.tags&&d.tags[0]||d.timestamp.substring(0,10)):(d.tags&&d.tags[0]?d.tags[0]:d.timestamp.substring(0,10)));
12358          svg+='<text x="'+x+'" y="'+(PT+CH+fsS*2)+'" text-anchor="middle" transform="rotate(30,'+x+','+(PT+CH+fsS*2)+')" font-family="'+FONT+'" font-size="'+fsS+'" fill="#7b675b">'+esc(lbl)+'</text>';
12359        }}
12360      }});
12361
12362      // Axis label
12363      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12364      svg+='<text x="'+(PL+CW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+xAxisLabel+'</text>';
12365      svg+='<text x="'+Math.round(14*sc)+'" y="'+(PT+CH/2)+'" text-anchor="middle" transform="rotate(-90,'+Math.round(14*sc)+','+(PT+CH/2)+')" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+(Y_LABELS[yKey]||yKey)+'</text>';
12366
12367      // Area fill + line path
12368      var pathD='';
12369      pts.forEach(function(d,i){{
12370        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12371        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12372        pathD+=(i===0?'M':'L')+x+','+y;
12373      }});
12374      if(pts.length>1){{
12375        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12376        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12377      }}
12378      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12379
12380      // Data points (clickable) + permanent value labels
12381      var showLabels = pts.length <= 40;
12382      var labelEveryN = pts.length > 20 ? 2 : 1;
12383      pts.forEach(function(d,i){{
12384        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12385        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12386        var hasTags=d.tags&&d.tags.length>0;
12387        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12388        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12389        svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
12390        if(showLabels && i%labelEveryN===0){{
12391          var lx=x, ly=y-r-5;
12392          svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmtFull(Number(d[yKey]))+'</text>';
12393        }}
12394      }});
12395
12396      svg+='</svg>';
12397      wrap.innerHTML=svg;
12398
12399      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12400      function lineYAt(mx){{
12401        var n=pts.length;
12402        if(n===0)return PT+CH;
12403        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12404        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12405        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12406        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12407        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12408        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12409        return y0+t*(y1-y0);
12410      }}
12411
12412      // SVG-level mousemove: show the value tooltip only when the pointer is over the
12413      // gradient fill (inside the chart and at/below the line) — never in the empty
12414      // space above the line. Cursor follows the same rule.
12415      (function(){{
12416        var svgEl=wrap.querySelector('svg');
12417        if(!svgEl)return;
12418        svgEl.addEventListener('mousemove',function(e){{
12419          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12420          var rect=svgEl.getBoundingClientRect();
12421          var scaleX=W/Math.max(rect.width,1);
12422          var scaleY=H/Math.max(rect.height,1);
12423          var mouseX=(e.clientX-rect.left)*scaleX;
12424          var mouseY=(e.clientY-rect.top)*scaleY;
12425          var ly=lineYAt(mouseX);
12426          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12427          svgEl.style.cursor='pointer';
12428          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12429          var d=pts[idx];
12430          var val=Number(d[yKey]);
12431          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12432          showTT(e,
12433            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12434            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12435            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12436          );
12437        }});
12438        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12439      }})();
12440
12441      // Attach point tooltips
12442      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12443        c.addEventListener('mouseover',function(e){{
12444          var d=pts[parseInt(this.dataset.idx)];
12445          var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
12446          var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
12447          showTT(e,
12448            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12449            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12450            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12451            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12452          );
12453          this.setAttribute('r','8');
12454        }});
12455        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12456        c.addEventListener('mousemove',moveTT);
12457        c.addEventListener('click',function(){{
12458          var d=pts[parseInt(this.dataset.idx)];
12459          if(d.html_url) window.open(d.html_url,'_blank');
12460        }});
12461      }});
12462    }}
12463
12464    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12465    var shProjFilter='', shBranchFilter='';
12466
12467    function fmtPST(isoStr){{
12468      if(!isoStr)return'';
12469      var d=new Date(isoStr);
12470      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12471      if(window.fmtTz){{var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}return window.fmtTz(d.getTime(),tz);}}
12472      function p(n){{return n<10?'0'+n:String(n);}}
12473      function nthWeekdaySun(year,month,n){{var count=0,day=1;while(true){{var t=new Date(Date.UTC(year,month,day));if(t.getUTCDay()===0&&++count===n)return t;day++;}}}}
12474      var yr=d.getUTCFullYear();
12475      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12476      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12477      var isDST=d>=dstStart&&d<dstEnd;
12478      var off=isDST?-7*3600*1000:-8*3600*1000;
12479      var lbl=isDST?'PDT':'PST';
12480      var loc=new Date(d.getTime()+off);
12481      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12482    }}
12483
12484    function getShRows(){{
12485      var proj=shProjFilter.toLowerCase().trim();
12486      var branch=shBranchFilter;
12487      return shData.filter(function(d){{
12488        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12489        if(branch&&(d.branch||'')!==branch)return false;
12490        return true;
12491      }});
12492    }}
12493
12494    function renderShPage(){{
12495      var filtered=getShRows();
12496      if(shSortCol){{
12497        filtered.sort(function(a,b){{
12498          var va,vb;
12499          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12500          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12501          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12502          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12503          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12504          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12505        }});
12506      }}
12507      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12508      shPage=Math.min(shPage,totalPages);
12509      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12510      var visible=filtered.slice(start,end);
12511      var tbody=document.getElementById('sh-tbody');
12512      if(!tbody)return;
12513      tbody.innerHTML=visible.map(function(d){{
12514        var tsHtml=esc(fmtPST(d.timestamp));
12515        var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">&#8212;</span>';
12516        var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12517        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12518        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12519        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12520        var reportCell='';
12521        if(d.html_url){{
12522          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12523          if(d.has_pdf){{var pdfUrl=d.html_url.replace(/\/html$/,'/pdf');reportCell+='<a class="btn primary rpt-btn" href="'+esc(pdfUrl)+'" target="_blank" rel="noopener">PDF</a>';}}
12524          reportCell+='</div>';
12525        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12526        if(d.submodule_links&&d.submodule_links.length){{
12527          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12528          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12529          reportCell+='</div></details>';
12530        }}
12531        return '<tr>'
12532          +'<td>'+tsHtml+'</td>'
12533          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12534          +'<td>'+runIdHtml+'</td>'
12535          +'<td>'+commitHtml+'</td>'
12536          +'<td>'+branchHtml+'</td>'
12537          +'<td>'+tags+'</td>'
12538          +'<td class="num">'+metricHtml+'</td>'
12539          +'<td class="report-cell">'+reportCell+'</td>'
12540          +'</tr>';
12541      }}).join('');
12542      var pgRange=document.getElementById('sh-pg-range');
12543      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12544      var pgInfo=document.getElementById('sh-pg-info');
12545      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12546      var pgBtns=document.getElementById('sh-pg-btns');
12547      if(pgBtns){{
12548        pgBtns.innerHTML='';
12549        function mkPgBtn(lbl,pg,active,disabled){{
12550          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12551          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12552          return b;
12553        }}
12554        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12555        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12556        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12557        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12558      }}
12559    }}
12560
12561    function wireTableBehavior(){{
12562      var pf=document.getElementById('sh-proj-filter');
12563      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12564      var bf=document.getElementById('sh-branch-filter');
12565      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12566      var rb=document.getElementById('sh-reset-btn');
12567      if(rb)rb.addEventListener('click',function(){{
12568        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12569        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12570        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12571        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12572        renderShPage();
12573      }});
12574      var pps=document.getElementById('sh-per-page');
12575      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12576      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12577      ths.forEach(function(th){{
12578        th.addEventListener('click',function(e){{
12579          if(e.target.classList.contains('col-resize-handle'))return;
12580          var col=th.dataset.col;
12581          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12582          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12583          th.classList.add('sort-'+shSortOrder);
12584          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12585          shPage=1;renderShPage();
12586        }});
12587      }});
12588      var table=document.getElementById('scan-history-table');
12589      if(!table)return;
12590      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12591      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12592      allThs.forEach(function(th,i){{
12593        var handle=th.querySelector('.col-resize-handle');
12594        if(!handle||!cols[i])return;
12595        var startX,startW;
12596        handle.addEventListener('mousedown',function(e){{
12597          e.stopPropagation();e.preventDefault();
12598          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12599          handle.classList.add('dragging');
12600          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12601          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12602          document.addEventListener('mousemove',onMove);
12603          document.addEventListener('mouseup',onUp);
12604        }});
12605      }});
12606    }}
12607
12608    function renderTable(pts, yKey){{
12609      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12610      var wrap=document.getElementById('data-table-wrap');
12611      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12612      var yLabel=Y_LABELS[yKey]||yKey||'';
12613      shData=pts.slice().reverse();
12614      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12615      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12616      var branches={{}};
12617      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12618      var branchOpts='<option value="">All branches</option>';
12619      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12620      wrap.innerHTML=
12621        '<div class="chart-section-header">SCAN HISTORY</div>'+
12622        '<div class="filter-row">'+
12623          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12624          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12625          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12626        '</div>'+
12627        '<div class="table-wrap">'+
12628        '<table id="scan-history-table" class="data-table">'+
12629        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12630        '<thead><tr id="sh-thead">'+
12631        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12632        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12633        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12634        '<th>Commit<div class="col-resize-handle"></div></th>'+
12635        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12636        '<th>Tags<div class="col-resize-handle"></div></th>'+
12637        '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12638        '<th>Report<div class="col-resize-handle"></div></th>'+
12639        '</tr></thead>'+
12640        '<tbody id="sh-tbody"></tbody>'+
12641        '</table>'+
12642        '</div>'+
12643        '<div class="pagination">'+
12644          '<span class="pagination-info" id="sh-pg-info"></span>'+
12645          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12646          '<div style="display:flex;align-items:center;gap:8px;">'+
12647            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12648            '<select class="filter-select" id="sh-per-page">'+
12649              '<option value="10">10 per page</option>'+
12650              '<option value="25" selected>25 per page</option>'+
12651              '<option value="50">50 per page</option>'+
12652              '<option value="100">100 per page</option>'+
12653            '</select>'+
12654            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12655          '</div>'+
12656        '</div>';
12657      wireTableBehavior();
12658      renderShPage();
12659    }}
12660
12661    function exportXLSX(){{
12662      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12663      var xbtn=document.getElementById('export-xlsx-btn');
12664      var xorig=xbtn?xbtn.innerHTML:'';
12665      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12666      var root=rootSel.value;
12667      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12668      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12669        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12670        buildAndDownloadXLSX(cm);
12671      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12672    }}
12673
12674    function buildAndDownloadXLSX(churnMap){{
12675      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12676      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12677      // (sorted is newest-first), so a given project/commit appears at most once.
12678      var seenPC={{}},dedup=[];
12679      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12680      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12681      var s1R=dedup.map(function(d){{
12682        var c=churnMap[d.run_id]||{{}};
12683        return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0,(+(c.added)||0)+(+(c.removed)||0)+(+(c.modified)||0)+(+(c.unmodified)||0)];
12684      }});
12685      var pm={{}};
12686      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12687      var s2H=['Project','Scan Count','First Scan','Latest Scan','Latest Code Lines','Latest Comment Lines','Latest Blank Lines','Latest Physical Lines','Latest Files','Min Code Lines','Max Code Lines','Avg Code Lines'];
12688      var s2R=Object.keys(pm).map(function(p){{
12689        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12690        var lat=sc[sc.length-1],fst=sc[0];
12691        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12692        var mn=Math.min.apply(null,codes),mx=Math.max.apply(null,codes),av=Math.round(codes.reduce(function(a,b){{return a+b;}},0)/codes.length);
12693        return[p,sc.length,fst.timestamp.substring(0,16).replace('T',' '),lat.timestamp.substring(0,16).replace('T',' '),+(lat.code_lines)||0,+(lat.comment_lines)||0,+(lat.blank_lines)||0,+(lat.physical_lines)||0,+(lat.files_analyzed)||0,mn,mx,av];
12694      }});
12695      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12696      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12697      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12698      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12699    }}
12700
12701    function buildXLSX(sheets,chartRows,chartRows2){{
12702      function s2b(s){{return new TextEncoder().encode(s);}}
12703      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12704      function col2l(n){{var s='';while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}}return s;}}
12705      function crc32(d){{
12706        if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
12707        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12708      }}
12709      function buildSheet(hdr,rows,drawRid,withCtrl){{
12710        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12711        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12712        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12713        x+='<row r="1">';
12714        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12715        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12716        x+='</row>';
12717        rows.forEach(function(row,ri){{
12718          var rn=ri+2;
12719          x+='<row r="'+rn+'">';
12720          row.forEach(function(cell,ci){{
12721            var addr=col2l(ci+1)+rn;
12722            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12723            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12724          }});
12725          if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\",\"Total\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+",P"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12726          x+='</row>';
12727        }});
12728        x+='</sheetData>';
12729        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12730        return x+'</worksheet>';
12731      }}
12732      function buildChartXML(rows){{
12733        var sn="'Scan History'";
12734        var nr=rows.length,er=nr+1;
12735        var sd=[{{name:'Code Lines',col:'F',di:5,clr:'C45C10'}},{{name:'Comment Lines',col:'G',di:6,clr:'4472C4'}},{{name:'Blank Lines',col:'H',di:7,clr:'70AD47'}},{{name:'Physical Lines',col:'I',di:8,clr:'7030A0'}}];
12736        var catCol='C',catIdx=2;
12737        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12738        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12739        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12740        x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Scan History \u2014 all metrics over time</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12741        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12742        sd.forEach(function(s,i){{
12743          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12744          x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12745          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12746          x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12747          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12748          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12749          x+='</c:strCache></c:strRef></c:cat>';
12750          x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12751          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12752          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12753        }});
12754        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12755        x+='<c:catAx><c:axId val="1"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="2"/></c:catAx>';
12756        x+='<c:valAx><c:axId val="2"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="1"/><c:crossBetween val="between"/></c:valAx>';
12757        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12758        return x;
12759      }}
12760      function buildChartXML2(rows){{
12761        var sn="'By Project'";
12762        var nr=rows.length,er=nr+1;
12763        var sd=[{{name:'Latest Code Lines',col:'E',di:4,clr:'C45C10'}},{{name:'Latest Comment Lines',col:'F',di:5,clr:'4472C4'}},{{name:'Latest Blank Lines',col:'G',di:6,clr:'70AD47'}},{{name:'Latest Physical Lines',col:'H',di:7,clr:'7030A0'}}];
12764        var catCol='A',catIdx=0;
12765        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12766        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12767        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12768        x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Latest metrics by project</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12769        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12770        sd.forEach(function(s,i){{
12771          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12772          x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12773          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12774          x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12775          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12776          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12777          x+='</c:strCache></c:strRef></c:cat>';
12778          x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12779          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12780          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12781        }});
12782        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12783        x+='<c:catAx><c:axId val="3"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="4"/></c:catAx>';
12784        x+='<c:valAx><c:axId val="4"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="3"/><c:crossBetween val="between"/></c:valAx>';
12785        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12786        return x;
12787      }}
12788      function buildChartXML3(rows){{
12789        var sn="'Scan History'";
12790        var nr=rows.length,er=nr+1;
12791        var catCol='C',catIdx=2;
12792        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12793        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12794        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12795        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12796        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12797        x+="<c:tx><c:strRef><c:f>'Focus Chart'!$B$1</c:f><c:strCache><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>";
12798        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12799        x+='<c:marker><c:symbol val="circle"/><c:size val="6"/><c:spPr><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr></c:marker>';
12800        x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="t"/></c:dLbls>';
12801        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12802        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12803        x+='</c:strCache></c:strRef></c:cat>';
12804        x+='<c:val><c:numRef><c:f>'+sn+'!$Q$2:$Q$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12805        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12806        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12807        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12808        x+='<c:catAx><c:axId val="5"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="6"/></c:catAx>';
12809        x+='<c:valAx><c:axId val="6"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="5"/><c:crossBetween val="between"/></c:valAx>';
12810        x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Single-Metric Focus</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12811        return x;
12812      }}
12813      function buildFocusSheet(drawRid){{
12814        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12815        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12816        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12817        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12818        x+='<sheetData><row r="1">';
12819        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12820        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12821        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12822        x+='</row></sheetData>';
12823        x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified,Total"</formula1></dataValidation></dataValidations>';
12824        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12825        return x+'</worksheet>';
12826      }}
12827      var hasChart=!!(chartRows&&chartRows.length);
12828      var nr=hasChart?chartRows.length:0;
12829      var hasChart2=!!(chartRows2&&chartRows2.length);
12830      var nr2=hasChart2?chartRows2.length:0;
12831      var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
12832      var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>';
12833      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12834      if(hasChart){{ct+='<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/charts/chart3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/drawings/drawing3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12835      if(hasChart2){{ct+='<Override PartName="/xl/charts/chart2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12836      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12837      var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
12838      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12839      sheets.forEach(function(s,i){{wbr+='<Relationship Id="rId'+(i+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}});
12840      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12841      var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>';
12842      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12843      wbx+='</sheets></workbook>';
12844      var files=[
12845        {{name:'[Content_Types].xml',data:s2b(ct)}},
12846        {{name:'_rels/.rels',data:s2b(dotrels)}},
12847        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12848        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12849        {{name:'xl/styles.xml',data:s2b(styl)}}
12850      ];
12851      // Chart embedded directly in Scan History (sheet1); By Project is plain
12852      sheets.forEach(function(s,i){{
12853        var sx;
12854        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12855        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12856        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12857      }});
12858      if(hasChart){{
12859        var fromRow=nr+4,toRow=nr+34;
12860        files.push({{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>')}});
12861        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12862        drx+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12863        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12864        drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12865        drx+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12866        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12867        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12868        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12869        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12870        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12871        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12872        files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/></Relationships>')}});
12873        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12874        files.push({{name:'xl/worksheets/_rels/sheet3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing3.xml"/></Relationships>')}});
12875        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12876        drx3+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12877        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12878        drx3+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12879        drx3+='<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>31</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12880        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12881        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12882        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12883        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12884        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12885        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12886        files.push({{name:'xl/drawings/_rels/drawing3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
12887        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12888      }}
12889      if(hasChart2){{
12890        var fromRow2=nr2+4,toRow2=nr2+36;
12891        files.push({{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing2.xml"/></Relationships>')}});
12892        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12893        drx2+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12894        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12895        drx2+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12896        drx2+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12897        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12898        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12899        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12900        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12901        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12902        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12903        files.push({{name:'xl/drawings/_rels/drawing2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart2.xml"/></Relationships>')}});
12904        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12905      }}
12906      var parts=[],offsets=[],total=0;
12907      files.forEach(function(f){{
12908        offsets.push(total);
12909        var nb=s2b(f.name),crc=crc32(f.data);
12910        var h=new DataView(new ArrayBuffer(30+nb.length));
12911        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12912        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12913        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12914        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12915        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12916        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12917        total+=30+nb.length+f.data.length;
12918      }});
12919      var cdStart=total;
12920      files.forEach(function(f,fi){{
12921        var nb=s2b(f.name),crc=crc32(f.data);
12922        var cd=new DataView(new ArrayBuffer(46+nb.length));
12923        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12924        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12925        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12926        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12927        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12928        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12929        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12930      }});
12931      var cdSz=total-cdStart;
12932      var eocd=new DataView(new ArrayBuffer(22));
12933      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12934      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12935      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12936      parts.push(new Uint8Array(eocd.buffer));
12937      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12938      var out=new Uint8Array(sz);var off=0;
12939      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12940      return out.buffer;
12941    }}
12942
12943    function trendTitleParts(){{
12944      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12945      var subSelEl=document.getElementById('sub-sel');
12946      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12947      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12948      var proj=(document.getElementById('root-sel').value)||'All projects';
12949      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12950      var cnt=(allData&&allData.length)||0;
12951      var now=new Date();
12952      function p2(n){{return(n<10?'0':'')+n;}}
12953      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12954      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12955    }}
12956
12957    function exportPNG(){{
12958      var svgEl=document.querySelector('#chart-wrap svg');
12959      if(!svgEl){{alert('No chart to export yet.');return;}}
12960      var svgStr=new XMLSerializer().serializeToString(svgEl);
12961      var vb=svgEl.viewBox.baseVal,scale=2;
12962      var headerH=84,footerH=36;
12963      var lw=(vb.width||900),lh=(vb.height||380);
12964      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12965      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12966      var url=URL.createObjectURL(blob);
12967      var img=new Image();
12968      var tp=trendTitleParts();
12969      img.onload=function(){{
12970        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12971        var ctx=canvas.getContext('2d');
12972        var cs=getComputedStyle(document.body);
12973        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12974        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12975        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12976        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12977        ctx.scale(scale,scale);
12978        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12979        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12980        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12981        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12982        ctx.strokeStyle=oxide;ctx.globalAlpha=0.55;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,74);ctx.lineTo(lw-24,74);ctx.stroke();ctx.globalAlpha=1;
12983        ctx.drawImage(img,0,headerH);
12984        var fy=headerH+lh;
12985        ctx.strokeStyle=oxide;ctx.globalAlpha=0.4;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(24,fy+9);ctx.lineTo(lw-24,fy+9);ctx.stroke();ctx.globalAlpha=1;
12986        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12987        ctx.fillText('\u00a9 2026 OxideSLOC  \u00b7  oxide-sloc v{version}  \u00b7  AGPL-3.0-or-later  \u00b7  github.com/oxide-sloc/oxide-sloc',lw/2,fy+27);
12988        ctx.textAlign='left';
12989        URL.revokeObjectURL(url);
12990        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12991      }};
12992      img.src=url;
12993    }}
12994
12995    function exportPDF(){{
12996      var svgEl=document.querySelector('#chart-wrap svg');
12997      if(!svgEl){{alert('No chart to export yet.');return;}}
12998      var tp=trendTitleParts();
12999      var svgStr=new XMLSerializer().serializeToString(svgEl);
13000      var statsEl=document.getElementById('trend-stats');
13001      var statsHtml=statsEl?statsEl.innerHTML:'';
13002      var yK=document.getElementById('y-sel').value;
13003      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13004      var yL=yLabels[yK]||yK;
13005      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13006      var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
13007      rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
13008      tableHtml+='</tbody></table>';
13009      var css='<style>'
13010        +'*{{box-sizing:border-box;}}'
13011        +'html,body{{margin:0;padding:0;}}'
13012        // Masthead/footer flow in document order — a position:fixed header repeats
13013        // on every printed page in Chromium and hides the rows beneath it on pages
13014        // 2+. The trend table's <thead> repeats per page natively instead.
13015        +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
13016        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13017        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13018        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13019        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13020        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13021        +'.rep-body{{padding:22px 34px 0;}}'
13022        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13023        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13024        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13025        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13026        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13027        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13028        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13029        +'.stat-chip-tip{{display:none!important;}}'
13030        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13031        +'.stat-chip-label{{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#7b675b;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13032        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13033        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13034        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13035        +'.rep-chart svg{{max-width:100%;height:auto;}}'
13036        +'.chart-section-header{{background:#191c26;color:#fff;padding:7px 13px;border-radius:4px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin:18px 0 10px;}}'
13037        +'.filter-row{{display:none!important;}}'
13038        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13039        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13040        +'th{{background:#f0e9e0;font-weight:800;}}'
13041        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13042        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13043        +'.rep-foot{{margin-top:22px;background:#191c26;color:rgba(255,255,255,0.72);padding:9px 34px;font-size:11px;font-weight:600;text-align:center;line-height:1.5;}}'
13044        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13045        +'</style>';
13046      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13047        +'<div class="rep-masthead"><div class="rep-mast-left"><span class="rep-mast-brand">oxide-sloc</span><span class="rep-mast-sub">Code Metrics Report \u00b7 Trend</span></div><div class="rep-mast-ts">Generated '+tp.date+'</div></div>'
13048        +'<div class="rep-body">'
13049        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13050        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13051        +'<div class="summary-strip">'+statsHtml+'</div>'
13052        +'<div class="rep-chart">'+svgStr+'</div>'
13053        +tableHtml
13054        +'</div>'
13055        +'<div class="rep-foot"><div>\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 local code metrics workbench \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc</div><div class="rep-foot-gen">Generated '+tp.date+'</div></div>'
13056        +'</body></html>';
13057      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13058    }}
13059
13060    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13061      var el=document.getElementById(id);
13062      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13063    }});
13064    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13065    // tracks the container like the responsive Chart.js charts do.
13066    var _rsT=null;
13067    window.addEventListener('resize',function(){{
13068      if(_rsT)clearTimeout(_rsT);
13069      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13070    }});
13071    rootSel.addEventListener('change',function(){{
13072      populateSubmodules(rootSel.value);
13073      loadAndRender();
13074    }});
13075    if(subSel)subSel.addEventListener('change',loadAndRender);
13076
13077    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13078    (function(){{
13079      var fvBtn=document.getElementById('tr-chart-fv-btn');
13080      if(!fvBtn)return;
13081      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13082      fvBtn.addEventListener('click',function(){{
13083        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13084        var yKey=document.getElementById('y-sel').value;
13085        var xMode=document.getElementById('x-sel').value;
13086        var pts=allData;
13087        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13088        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13089        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13090        var tp=trendTitleParts();
13091        var ov=document.createElement('div');
13092        ov.className='tr-chart-full-modal';
13093        ov.innerHTML='<div class="tr-chart-full-inner">'
13094          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13095          +'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'
13096          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13097          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13098          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13099        document.body.appendChild(ov);
13100        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13101        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13102        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13103        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13104        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13105      }});
13106    }})();
13107
13108    var xlsxBtn=document.getElementById('export-xlsx-btn');
13109    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13110    var pngBtn=document.getElementById('export-png-btn');
13111    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13112    var pdfBtn=document.getElementById('export-pdf-btn');
13113    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13114
13115    // ── Clean-up modal ───────────────────────────────────────────────────────
13116    (function(){{
13117      var triggerBtn=document.getElementById('cleanup-runs-btn');
13118      if(!triggerBtn)return;
13119      var modal=document.createElement('div');
13120      modal.className='tr-modal-backdrop';
13121      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13122        +'<div class="tr-modal-head">'
13123        +'<div class="tr-modal-icon danger"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg></div>'
13124        +'<div><h2 class="tr-modal-title">Clean up old runs</h2><p class="tr-modal-sub">One-shot deletion of older scan artifacts</p></div>'
13125        +'</div>'
13126        +'<div class="tr-modal-body">'
13127        +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
13128        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13129        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13130        +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
13131        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13132        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13133        +'</div>'
13134        +'<div class="tr-modal-foot">'
13135        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13136        +'<button class="tr-btn tr-btn-danger" id="cleanup-confirm-btn" type="button"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete old runs</button>'
13137        +'</div></div>';
13138      document.body.appendChild(modal);
13139      triggerBtn.addEventListener('click',function(){{
13140        document.getElementById('cleanup-status').style.display='none';
13141        modal.style.display='flex';
13142      }});
13143      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13144      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13145      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13146        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13147        var confirmBtn=this;
13148        confirmBtn.disabled=true;
13149        var status=document.getElementById('cleanup-status');
13150        status.style.display='block';
13151        status.style.background='#dbeafe';status.style.color='#1e40af';
13152        status.textContent='Deleting\u2026';
13153        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13154        .then(function(resp){{
13155          return resp.json().then(function(d){{
13156            if(resp.ok){{
13157              status.style.background='#dcfce7';status.style.color='#166534';
13158              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13159              setTimeout(function(){{window.location.reload();}},1500);
13160            }}else{{
13161              status.style.background='#fee2e2';status.style.color='#991b1b';
13162              status.textContent='Error: '+(d.error||'Unexpected error');
13163              confirmBtn.disabled=false;
13164            }}
13165          }});
13166        }})
13167        .catch(function(e){{
13168          status.style.background='#fee2e2';status.style.color='#991b1b';
13169          status.textContent='Network error: '+String(e);
13170          confirmBtn.disabled=false;
13171        }});
13172      }});
13173    }})();
13174
13175    // ── Retention policy panel ────────────────────────────────────────────────
13176    (function(){{
13177      var triggerBtn=document.getElementById('retention-policy-btn');
13178      if(!triggerBtn)return;
13179      var modal=document.createElement('div');
13180      modal.className='tr-modal-backdrop';
13181      modal.style.zIndex='9001';
13182      modal.innerHTML=''
13183        +'<div class="tr-modal" style="max-width:640px;">'
13184        +'<div class="tr-modal-head">'
13185        +'<div class="tr-modal-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg></div>'
13186        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13187        +'</div>'
13188        +'<div class="tr-modal-body">'
13189        +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
13190        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13191        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13192        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13193        +'</div>'
13194        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13195        +'<div>'
13196        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
13197        +'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13198        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13199        +'</div>'
13200        +'<div>'
13201        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
13202        +'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13203        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13204        +'</div>'
13205        +'</div>'
13206        +'<div style="margin-bottom:20px;">'
13207        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
13208        +'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
13209        +'<option value="1">Every hour</option>'
13210        +'<option value="6">Every 6 hours</option>'
13211        +'<option value="12">Every 12 hours</option>'
13212        +'<option value="24" selected>Every 24 hours</option>'
13213        +'<option value="48">Every 2 days</option>'
13214        +'<option value="72">Every 3 days</option>'
13215        +'<option value="168">Every week</option>'
13216        +'</select>'
13217        +'</div>'
13218        +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
13219        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13220        +'</div>'
13221        +'<div class="tr-modal-foot">'
13222        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13223        +'<button class="tr-btn tr-btn-secondary" id="rp-run-now-btn" type="button"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run Now</button>'
13224        +'<button class="tr-btn tr-btn-primary" id="rp-save-btn" type="button"><svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save Policy</button>'
13225        +'</div>'
13226        +'</div>';
13227      document.body.appendChild(modal);
13228
13229      function rpShowStatus(msg,ok){{
13230        var s=document.getElementById('rp-status');
13231        s.style.display='block';
13232        s.style.background=ok?'#dcfce7':'#fee2e2';
13233        s.style.color=ok?'#166534':'#991b1b';
13234        s.textContent=msg;
13235      }}
13236      function fmtAgo(iso){{
13237        if(!iso)return'Never';
13238        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13239        if(diff<60)return diff+'s ago';
13240        if(diff<3600)return Math.floor(diff/60)+'m ago';
13241        if(diff<86400)return Math.floor(diff/3600)+'h ago';
13242        return Math.floor(diff/86400)+'d ago';
13243      }}
13244      function loadPolicy(){{
13245        fetch('/api/cleanup-policy')
13246          .then(function(r){{return r.json();}})
13247          .then(function(d){{
13248            var p=d.policy;
13249            document.getElementById('rp-enabled').checked=p?p.enabled:false;
13250            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13251            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13252            var sel=document.getElementById('rp-interval');
13253            if(p){{var iv=String(p.interval_hours||24);for(var i=0;i<sel.options.length;i++){{if(sel.options[i].value===iv){{sel.selectedIndex=i;break;}}}}}}
13254            var lr=document.getElementById('rp-last-run');
13255            if(d.last_run_at){{
13256              lr.textContent='Last run: '+fmtAgo(d.last_run_at)+(d.last_run_deleted!=null?' \u00b7 deleted '+d.last_run_deleted+' run'+(d.last_run_deleted===1?'':'s'):'');
13257            }}else{{
13258              lr.textContent='Auto-cleanup has not run yet.';
13259            }}
13260          }})
13261          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13262      }}
13263
13264      triggerBtn.addEventListener('click',function(){{
13265        document.getElementById('rp-status').style.display='none';
13266        loadPolicy();
13267        modal.style.display='flex';
13268      }});
13269      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13270      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13271
13272      document.getElementById('rp-save-btn').addEventListener('click',function(){{
13273        var enabled=document.getElementById('rp-enabled').checked;
13274        var ageVal=document.getElementById('rp-max-age').value.trim();
13275        var countVal=document.getElementById('rp-max-count').value.trim();
13276        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13277        if(enabled&&!ageVal&&!countVal){{
13278          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13279          return;
13280        }}
13281        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13282        var saveBtn=document.getElementById('rp-save-btn');
13283        saveBtn.disabled=true;
13284        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13285          .then(function(r){{
13286            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13287            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13288          }})
13289          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13290          .finally(function(){{saveBtn.disabled=false;}});
13291      }});
13292
13293      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13294        var btn=this;
13295        var orig=btn.innerHTML;
13296        btn.disabled=true;
13297        btn.textContent='Running\u2026';
13298        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13299          .then(function(r){{return r.json();}})
13300          .then(function(d){{
13301            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13302            loadPolicy();
13303          }})
13304          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13305          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13306      }});
13307    }})();
13308
13309    populateSubmodules(rootSel.value);
13310    loadAndRender();
13311
13312    (function randomizeWatermarks() {{
13313      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13314      if (!wms.length) return;
13315      var placed = [];
13316      function tooClose(top, left) {{
13317        for (var i = 0; i < placed.length; i++) {{
13318          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13319          if (dt < 16 && dl < 12) return true;
13320        }}
13321        return false;
13322      }}
13323      function pick(leftBand) {{
13324        for (var attempt = 0; attempt < 50; attempt++) {{
13325          var top = Math.random() * 88 + 2;
13326          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13327          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13328        }}
13329        var top = Math.random() * 88 + 2;
13330        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13331        placed.push([top, left]); return [top, left];
13332      }}
13333      var half = Math.floor(wms.length / 2);
13334      wms.forEach(function (img, i) {{
13335        var pos = pick(i < half);
13336        var size = Math.floor(Math.random() * 100 + 120);
13337        var rot = (Math.random() * 360).toFixed(1);
13338        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13339        img.style.width=size+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
13340      }});
13341    }})();
13342    (function spawnCodeParticles() {{
13343      var container = document.getElementById('code-particles');
13344      if (!container) return;
13345      var snippets = [
13346        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13347        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13348        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13349        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13350        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13351      ];
13352      var count = 38;
13353      for (var i = 0; i < count; i++) {{
13354        (function(idx) {{
13355          var el = document.createElement('span');
13356          el.className = 'code-particle';
13357          el.textContent = snippets[idx % snippets.length];
13358          var left = Math.random() * 94 + 2;
13359          var top = Math.random() * 88 + 6;
13360          var dur = (Math.random() * 10 + 9).toFixed(1);
13361          var delay = (Math.random() * 18).toFixed(1);
13362          var rot = (Math.random() * 26 - 13).toFixed(1);
13363          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13364          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13365          container.appendChild(el);
13366        }})(i);
13367      }}
13368    }})();
13369  </script>
13370  <footer class="site-footer">
13371    local code analysis - metrics, history and reports
13372    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13373    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13374    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13375    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13376    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13377  </footer>
13378  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
13379  {toast_assets}
13380</body>
13381</html>"##,
13382    );
13383
13384    Html(html).into_response()
13385}
13386
13387fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13388    use std::collections::HashMap;
13389    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13390        return vec![];
13391    }
13392    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13393    for rec in per_file_records {
13394        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13395            let e = totals.entry(lang.display_name().to_string()).or_default();
13396            e.0 += u64::from(cov.lines_found);
13397            e.1 += u64::from(cov.lines_hit);
13398        }
13399    }
13400    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13401    let mut pairs: Vec<(String, f64)> = totals
13402        .into_iter()
13403        .filter(|(_, (found, _))| *found > 0)
13404        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13405        .collect();
13406    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13407    pairs
13408        .iter()
13409        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13410        .collect()
13411}
13412
13413fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13414    let mut high = 0u64;
13415    let mut mid = 0u64;
13416    let mut low = 0u64;
13417    for rec in per_file_records {
13418        if let Some(cov) = &rec.coverage {
13419            if cov.lines_found == 0 {
13420                continue;
13421            }
13422            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13423            if pct >= 80.0 {
13424                high += 1;
13425            } else if pct >= 50.0 {
13426                mid += 1;
13427            } else {
13428                low += 1;
13429            }
13430        }
13431    }
13432    (high, mid, low)
13433}
13434
13435fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13436    let mut arr: Vec<serde_json::Value> = per_file_records
13437        .iter()
13438        .filter_map(|rec| {
13439            rec.coverage.as_ref().map(|cov| {
13440                let line_pct = if cov.lines_found > 0 {
13441                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13442                        / 10.0
13443                } else {
13444                    0.0
13445                };
13446                let fn_pct = if cov.functions_found > 0 {
13447                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13448                        .round()
13449                        / 10.0
13450                } else {
13451                    -1.0
13452                };
13453                serde_json::json!({
13454                    "rel": rec.relative_path,
13455                    "lang": rec.language.map_or("?", |l| l.display_name()),
13456                    "line_pct": line_pct,
13457                    "fn_pct": fn_pct,
13458                    "lhit": cov.lines_hit,
13459                    "lfound": cov.lines_found,
13460                    "fhit": cov.functions_hit,
13461                    "ffound": cov.functions_found,
13462                })
13463            })
13464        })
13465        .collect();
13466    arr.sort_by(|a, b| {
13467        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13468        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13469        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13470    });
13471    arr
13472}
13473
13474#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13475fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13476    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13477        .totals_by_language
13478        .iter()
13479        .filter(|l| l.test_count > 0)
13480        .collect();
13481    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13482    let lang_tests: Vec<serde_json::Value> = langs
13483        .iter()
13484        .map(|l| {
13485            let d = if l.code_lines > 0 {
13486                l.test_count as f64 / l.code_lines as f64 * 1000.0
13487            } else {
13488                0.0
13489            };
13490            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13491                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13492                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13493        })
13494        .collect();
13495    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13496    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13497    let t = &run.summary_totals;
13498    let total_tests = t.test_count;
13499    let density = if t.code_lines > 0 {
13500        total_tests as f64 / t.code_lines as f64 * 1000.0
13501    } else {
13502        0.0
13503    };
13504    let most_tested = langs.first().map_or_else(
13505        || "\u{2014}".to_string(),
13506        |l| l.language.display_name().to_string(),
13507    );
13508    let test_files: u64 = run
13509        .per_file_records
13510        .iter()
13511        .filter(|f| f.raw_line_categories.test_count > 0)
13512        .count() as u64;
13513    let cov_line = if t.coverage_lines_found > 0 {
13514        format!(
13515            "{:.1}",
13516            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13517        )
13518    } else {
13519        "0".to_string()
13520    };
13521    let cov_fn = if t.coverage_functions_found > 0 {
13522        format!(
13523            "{:.1}",
13524            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13525        )
13526    } else {
13527        "0".to_string()
13528    };
13529    let cov_branch = if t.coverage_branches_found > 0 {
13530        format!(
13531            "{:.1}",
13532            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13533        )
13534    } else {
13535        "0".to_string()
13536    };
13537    let has_cov = !cov_arr.is_empty();
13538    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13539    serde_json::json!({
13540        "totals": {
13541            "test_count": total_tests,
13542            "assertions": t.test_assertion_count,
13543            "suites": t.test_suite_count,
13544            "test_files": test_files,
13545            "total_files": t.files_analyzed,
13546            "density_str": format!("{density:.1}"),
13547            "most_tested": most_tested,
13548            "langs_with_tests": langs.len(),
13549            "cov_line": cov_line,
13550            "cov_fn": cov_fn,
13551            "cov_branch": cov_branch,
13552        },
13553        "lang_tests": lang_tests,
13554        "cov": cov_arr,
13555        "cov_tiers": {"high": high, "mid": mid, "low": low},
13556        "file_cov": file_cov_arr,
13557        "has_coverage": has_cov,
13558        "submodules": {},
13559    })
13560}
13561
13562#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13563fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13564    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13565        .language_summaries
13566        .iter()
13567        .filter(|l| l.test_count > 0)
13568        .collect();
13569    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13570    let lang_tests: Vec<serde_json::Value> = langs
13571        .iter()
13572        .map(|l| {
13573            let d = if l.code_lines > 0 {
13574                l.test_count as f64 / l.code_lines as f64 * 1000.0
13575            } else {
13576                0.0
13577            };
13578            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13579                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13580                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13581        })
13582        .collect();
13583    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13584    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13585    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13586    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13587    let density = if sub.code_lines > 0 {
13588        total_tests as f64 / sub.code_lines as f64 * 1000.0
13589    } else {
13590        0.0
13591    };
13592    let most_tested = langs.first().map_or_else(
13593        || "\u{2014}".to_string(),
13594        |l| l.language.display_name().to_string(),
13595    );
13596    serde_json::json!({
13597        "totals": {
13598            "test_count": total_tests,
13599            "assertions": total_assertions,
13600            "suites": total_suites,
13601            "test_files": test_files_approx,
13602            "total_files": sub.files_analyzed,
13603            "density_str": format!("{density:.1}"),
13604            "most_tested": most_tested,
13605            "langs_with_tests": langs.len(),
13606            "cov_line": "0",
13607            "cov_fn": "0",
13608            "cov_branch": "0",
13609        },
13610        "lang_tests": lang_tests,
13611        "cov": [],
13612        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13613        "has_coverage": false,
13614    })
13615}
13616
13617fn compute_cov_json_str(run: &AnalysisRun) -> String {
13618    use std::collections::HashMap;
13619    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13620    for rec in &run.per_file_records {
13621        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13622            let e = totals.entry(lang.display_name().to_string()).or_default();
13623            e.0 += u64::from(cov.lines_found);
13624            e.1 += u64::from(cov.lines_hit);
13625        }
13626    }
13627    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13628    let mut pairs: Vec<(String, f64)> = totals
13629        .into_iter()
13630        .filter(|(_, (found, _))| *found > 0)
13631        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13632        .collect();
13633    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13634    let parts: Vec<String> = pairs
13635        .iter()
13636        .map(|(lang, pct)| {
13637            let name = lang.replace('"', "\\\"");
13638            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13639        })
13640        .collect();
13641    format!("[{}]", parts.join(","))
13642}
13643
13644fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13645    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13646    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13647}
13648
13649fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13650    let mut entry = build_test_scope_entry(run);
13651    if !run.submodule_summaries.is_empty() {
13652        let subs: serde_json::Map<String, serde_json::Value> = run
13653            .submodule_summaries
13654            .iter()
13655            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13656            .collect();
13657        entry["submodules"] = serde_json::Value::Object(subs);
13658    }
13659    entry
13660}
13661
13662fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13663    let name = l.language.display_name().replace('"', "\\\"");
13664    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13665    let density = if l.code_lines > 0 {
13666        l.test_count as f64 / l.code_lines as f64 * 1000.0
13667    } else {
13668        0.0
13669    };
13670    format!(
13671        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13672        name = name,
13673        t = l.test_count,
13674        a = l.test_assertion_count,
13675        s = l.test_suite_count,
13676        c = l.code_lines,
13677        d = density,
13678        f = l.files,
13679    )
13680}
13681
13682fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13683    let Some(r) = run else {
13684        return "[]".to_string();
13685    };
13686    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13687        .totals_by_language
13688        .iter()
13689        .filter(|l| l.test_count > 0)
13690        .collect();
13691    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13692    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13693    format!("[{}]", parts.join(","))
13694}
13695
13696/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13697async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13698    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13699    scope_map.insert(
13700        "__all__".to_string(),
13701        latest_run.map_or_else(
13702            || {
13703                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13704                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13705                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13706                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13707                    "has_coverage":false,"submodules":{}})
13708            },
13709            build_test_scope_entry,
13710        ),
13711    );
13712    let all_roots: Vec<String> = {
13713        let reg = state.registry.lock().await;
13714        let mut seen = std::collections::BTreeSet::new();
13715        reg.entries
13716            .iter()
13717            .flat_map(|e| e.input_roots.iter().cloned())
13718            .filter(|r| seen.insert(r.clone()))
13719            .collect()
13720    };
13721    for root in &all_roots {
13722        let json_path = {
13723            let reg = state.registry.lock().await;
13724            reg.entries
13725                .iter()
13726                .find(|e| e.input_roots.iter().any(|r| r == root))
13727                .and_then(|e| e.json_path.clone())
13728        };
13729        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13730            let json_str = tokio::fs::read_to_string(&p).await.ok();
13731            json_str
13732                .as_deref()
13733                .and_then(|s| serde_json::from_str(s).ok())
13734        } else {
13735            None
13736        };
13737        if let Some(ref run) = run_for_root {
13738            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13739        }
13740    }
13741    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13742}
13743
13744// GET /test-metrics
13745#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13746#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13747async fn test_metrics_handler(
13748    State(state): State<AppState>,
13749    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13750) -> Response {
13751    auto_scan_watched_dirs(&state).await;
13752    let watched_dirs_list: Vec<String> = {
13753        let wd = state.watched_dirs.lock().await;
13754        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13755    };
13756    let latest_run: Option<AnalysisRun> = {
13757        let json_path = {
13758            let reg = state.registry.lock().await;
13759            reg.entries.first().and_then(|e| e.json_path.clone())
13760        };
13761        if let Some(p) = json_path {
13762            let json_str = tokio::fs::read_to_string(&p).await.ok();
13763            json_str
13764                .as_deref()
13765                .and_then(|s| serde_json::from_str(s).ok())
13766        } else {
13767            None
13768        }
13769    };
13770
13771    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13772    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13773
13774    // Build coverage chart JSON (per-language avg line coverage %).
13775    let cov_json: String = latest_run
13776        .as_ref()
13777        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13778        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13779
13780    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13781    let _cov_tier_json: String = latest_run
13782        .as_ref()
13783        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13784        .map_or_else(
13785            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13786            compute_cov_tier_json_str,
13787        );
13788
13789    let total_tests: u64 = latest_run
13790        .as_ref()
13791        .map_or(0, |r| r.summary_totals.test_count);
13792    let total_assertions: u64 = latest_run
13793        .as_ref()
13794        .map_or(0, |r| r.summary_totals.test_assertion_count);
13795    let total_suites: u64 = latest_run
13796        .as_ref()
13797        .map_or(0, |r| r.summary_totals.test_suite_count);
13798    let total_code: u64 = latest_run
13799        .as_ref()
13800        .map_or(0, |r| r.summary_totals.code_lines);
13801    let workspace_density: f64 = if total_code > 0 {
13802        total_tests as f64 / total_code as f64 * 1000.0
13803    } else {
13804        0.0
13805    };
13806    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13807        r.totals_by_language
13808            .iter()
13809            .filter(|l| l.test_count > 0)
13810            .count()
13811    });
13812    let most_tested: String = latest_run
13813        .as_ref()
13814        .and_then(|r| {
13815            r.totals_by_language
13816                .iter()
13817                .filter(|l| l.test_count > 0)
13818                .max_by_key(|l| l.test_count)
13819        })
13820        .map_or_else(
13821            || "\u{2014}".to_string(),
13822            |l| l.language.display_name().to_string(),
13823        );
13824    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13825        r.per_file_records
13826            .iter()
13827            .filter(|f| f.raw_line_categories.test_count > 0)
13828            .count() as u64
13829    });
13830    let total_files_analyzed: u64 = latest_run
13831        .as_ref()
13832        .map_or(0, |r| r.summary_totals.files_analyzed);
13833    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13834
13835    // Aggregated coverage percentages from summary_totals
13836    let cov_line_pct_str: String = latest_run
13837        .as_ref()
13838        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13839        .map_or_else(
13840            || "0".to_string(),
13841            |r| {
13842                format!(
13843                    "{:.1}",
13844                    r.summary_totals.coverage_lines_hit as f64
13845                        / r.summary_totals.coverage_lines_found as f64
13846                        * 100.0
13847                )
13848            },
13849        );
13850    let cov_fn_pct_str: String = latest_run
13851        .as_ref()
13852        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13853        .map_or_else(
13854            || "0".to_string(),
13855            |r| {
13856                format!(
13857                    "{:.1}",
13858                    r.summary_totals.coverage_functions_hit as f64
13859                        / r.summary_totals.coverage_functions_found as f64
13860                        * 100.0
13861                )
13862            },
13863        );
13864    let cov_branch_pct_str: String = latest_run
13865        .as_ref()
13866        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13867        .map_or_else(
13868            || "0".to_string(),
13869            |r| {
13870                format!(
13871                    "{:.1}",
13872                    r.summary_totals.coverage_branches_hit as f64
13873                        / r.summary_totals.coverage_branches_found as f64
13874                        * 100.0
13875                )
13876            },
13877        );
13878
13879    let cov_no_data_notice = if has_coverage {
13880        String::new()
13881    } else {
13882        String::from(
13883            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13884<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
13885<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13886  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13887  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
13888  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13889  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
13890  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13891  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
13892  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13893  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
13894  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13895  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
13896</div>
13897<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13898</div>"#,
13899        )
13900    };
13901
13902    let workspace_density_str = format!("{workspace_density:.1}");
13903    let nonce = &csp_nonce;
13904    let toast_assets = sloc_toast_assets(nonce);
13905    let version = env!("CARGO_PKG_VERSION");
13906
13907    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13908    // of interactive controls — folder watching is managed by the host administrator.
13909    let watched_dirs_html: String = if state.server_mode {
13910        r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13911    } else {
13912        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13913            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13914                .to_string()
13915        } else {
13916            watched_dirs_list
13917                .iter()
13918                .fold(String::new(), |mut s, d| {
13919                    use std::fmt::Write as _;
13920                    let escaped =
13921                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13922                    write!(
13923                        s,
13924                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
13925                    ).expect("write to String is infallible");
13926                    s
13927                })
13928        };
13929        format!(
13930            r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
13931        )
13932    };
13933
13934    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13935    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13936
13937    let html = format!(
13938        r#"<!doctype html>
13939<html lang="en">
13940<head>
13941  <meta charset="utf-8" />
13942  <meta name="viewport" content="width=device-width, initial-scale=1" />
13943  <title>OxideSLOC | Test Metrics</title>
13944  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13945  <style nonce="{nonce}">
13946    :root {{
13947      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13948      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13949      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13950      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13951      --info-bg:#eef3ff; --info-text:#4467d8;
13952    }}
13953    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13954    *{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
13955    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13956    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13957    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
13958    @keyframes floatCode{{0%{{opacity:0;transform:translateY(0) rotate(var(--rot));}}10%{{opacity:var(--op);}}85%{{opacity:var(--op);}}100%{{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}}}
13959    .top-nav{{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}}
13960    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13961    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}
13962    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13963    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}} .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
13964    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13965    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13966    @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
13967    .nav-pill,.theme-toggle{{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
13968    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13969    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13970    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13971    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13972    .status-dot{{width:8px;height:8px;border-radius:999px;background:#26d768;box-shadow:0 0 0 4px rgba(38,215,104,0.14);flex:0 0 auto;}}
13973    .server-status-wrap{{position:relative;display:inline-flex;}}.server-online-pill{{cursor:default;}}.server-status-tip{{display:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);}}.server-status-tip::before{{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}}.server-status-wrap:hover .server-status-tip,.server-status-wrap:focus-within .server-status-tip{{display:block;}}
13974    .nav-dropdown{{position:relative;display:inline-flex;}}.nav-dropdown-btn{{cursor:pointer;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:#fff;border-radius:999px;padding:0 14px;min-height:38px;font-size:12px;font-weight:700;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;text-decoration:none;}}.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);}}.nav-dropdown-menu{{opacity:0;visibility:hidden;position:absolute;top:calc(100% + 8px);right:0;background:linear-gradient(180deg,var(--nav),var(--nav-2));border:1px solid rgba(255,255,255,0.15);border-radius:12px;min-width:165px;overflow:hidden;box-shadow:0 10px 28px rgba(0,0,0,0.28);z-index:100;transition:opacity 0.13s ease,visibility 0s ease 0.13s;}}.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity 0.13s ease,visibility 0s ease 0s;}}.nav-dropdown-menu a{{display:flex;align-items:center;gap:9px;padding:11px 16px;color:rgba(255,255,255,0.92);text-decoration:none;font-size:12px;font-weight:700;border-bottom:1px solid rgba(255,255,255,0.10);}}.nav-dropdown-menu a:last-child{{border-bottom:none;}}.nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}.nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
13975    .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
13976    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13977    .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
13978    .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
13979    .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13980    .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13981    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13982    .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
13983    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13984    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13985    .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
13986    .tz-select:focus{{border-color:var(--oxide);}}
13987    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13988    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13989    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13990    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13991    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13992    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13993    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13994    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
13995    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13996    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13997    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13998    .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
13999    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}}
14000    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14001    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14002    .section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
14003    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14004    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14005    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14006    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14007    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14008    .chart-canvas-wrap{{position:relative;height:280px;}}
14009    .chart-no-data{{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;border:1px dashed var(--line-strong);border-radius:10px;color:var(--muted);font-size:13px;gap:10px;}}
14010    .chart-no-data svg{{opacity:0.35;}}
14011    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14012    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14013    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14014    .data-table th{{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:8px 12px;border-bottom:2px solid var(--line);white-space:nowrap;}}
14015    .data-table td{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
14016    .data-table tr:last-child td{{border-bottom:none;}}
14017    .data-table tbody tr:hover td{{background:var(--surface-2);}}
14018    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14019    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14020    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14021    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14022    .cov-gauge-card{{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);min-width:0;}}
14023    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14024    .cov-gauge-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:10px 14px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.55;white-space:normal;max-width:300px;min-width:180px;text-align:left;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
14025    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14026    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14027    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14028    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14029    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14030    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14031    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14032    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14033    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14034    .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
14035    .chart-select:focus{{border-color:var(--accent);}}
14036    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14037    .trend-canvas-wrap{{position:relative;height:260px;}}
14038    .trend-controls-bar{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
14039    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14040    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14041    .site-footer a{{color:var(--muted);}}
14042    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14043    .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .13s;}}
14044    .btn:hover{{background:var(--surface-2);}}
14045    .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);text-decoration:none;white-space:nowrap;transition:background .12s ease;}}
14046    .export-btn:hover{{background:var(--line);}}
14047    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14048    /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14049    .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14050    .scope-export{{margin-left:auto;}}
14051    body.pdf-mode .export-group{{display:none!important;}}
14052    @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14053    .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
14054    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14055    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14056    .scope-sel{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;max-width:500px;}}
14057    .scope-sel:focus{{border-color:var(--accent);}}
14058    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14059    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
14060    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14061    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14062    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14063    .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
14064    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14065    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14066    .watched-chip-rm:hover{{color:var(--oxide);}}
14067    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14068    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14069    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14070    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14071    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14072    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14073    .cov-tab{{padding:4px 12px;border-radius:20px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}}
14074    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14075    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14076    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14077    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14078    .cov-file-search{{flex:1;min-width:160px;max-width:340px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;outline:none;}}
14079    .cov-file-search:focus{{border-color:var(--accent);}}
14080    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14081    .cov-file-path{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--text);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14082    body.dark-theme .cov-file-search{{background:var(--surface);}}
14083    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14084    .chart-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
14085    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14086    .chart-modal-overlay{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
14087    .chart-modal{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1200px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
14088    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14089    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14090    .chart-modal-close{{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}}
14091    .chart-modal-close:hover{{opacity:.7;}}
14092    body.dark-theme .chart-modal{{background:var(--surface);}}
14093  </style>
14094</head>
14095<body>
14096  <div class="background-watermarks" aria-hidden="true">
14097    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14098    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14099    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14100    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14101    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14102    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14103  </div>
14104  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14105  <div class="top-nav">
14106    <div class="top-nav-inner">
14107      <a class="brand" href="/">
14108        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14109        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14110      </a>
14111      <div class="nav-right">
14112        <a class="nav-pill" href="/">Home</a>
14113        <div class="nav-dropdown">
14114          <a href="/view-reports" class="nav-dropdown-btn">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
14115          <div class="nav-dropdown-menu">
14116            <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
14117          </div>
14118        </div>
14119        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14120        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14121        <div class="nav-dropdown">
14122          <a href="/git-browser" class="nav-dropdown-btn">Git Browser <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
14123          <div class="nav-dropdown-menu">
14124            <a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
14125          </div>
14126        </div>
14127        <div class="server-status-wrap" id="server-status-wrap">
14128          <div class="nav-pill server-online-pill" id="server-status-pill">
14129            <span class="status-dot" id="status-dot"></span>
14130            <span id="server-status-label">Server</span>
14131            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14132          </div>
14133          <div class="server-status-tip">
14134            OxideSLOC is running — accessible on your network.
14135            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14136          </div>
14137        </div>
14138        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14139          <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
14140        </button>
14141        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14142          <svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
14143          <svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
14144        </button>
14145      </div>
14146    </div>
14147  </div>
14148
14149  <div class="page">
14150    {watched_dirs_html}
14151    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14152      <div class="scan-overlay-card">
14153        <div class="scan-spinner"></div>
14154        <div class="scan-overlay-text">Scanning folder…</div>
14155        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14156      </div>
14157    </div>
14158    <style>
14159    .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
14160    .scan-overlay.active{{display:flex;}}
14161    .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
14162    .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
14163    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14164    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14165    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14166    </style>
14167    <div class="scope-bar">
14168      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
14169      <span class="scope-label">Scope</span>
14170      <div class="scope-sel-wrap">
14171        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14172        <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
14173          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
14174          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14175        </div>
14176      </div>
14177      <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14178      <div class="export-group scope-export" id="tm-export-group">
14179        <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download the whole page (Test Metrics + LCOV Coverage Summary) as an Excel workbook (.xlsx)">
14180          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
14181          Export Excel
14182        </button>
14183        <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14184          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
14185          Export PNG
14186        </button>
14187        <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14188          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
14189          Export PDF
14190        </button>
14191      </div>
14192    </div>
14193    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14194      <div class="stat-chip"><div class="stat-chip-val" id="chip-total">{total_tests}</div><div class="stat-chip-label">Test Functions</div><div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div><div class="stat-chip-exact" id="chip-total-exact"></div></div>
14195      <div class="stat-chip"><div class="stat-chip-val" id="chip-assertions">{total_assertions}</div><div class="stat-chip-label">Assertions</div><div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div><div class="stat-chip-exact" id="chip-assertions-exact"></div></div>
14196      <div class="stat-chip"><div class="stat-chip-val" id="chip-suites">{total_suites}</div><div class="stat-chip-label">Test Suites</div><div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div></div>
14197      <div class="stat-chip"><div class="stat-chip-val" id="chip-test-files">{test_files_count} / {total_files_analyzed}</div><div class="stat-chip-label">Test Files</div><div class="stat-chip-tip">Files containing at least one test definition out of total analyzed files</div><div class="stat-chip-exact" id="chip-test-files-exact"></div></div>
14198    </div>
14199    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14200      <div class="stat-chip"><div class="stat-chip-val" id="chip-density">{workspace_density_str}</div><div class="stat-chip-label">Tests per 1K SLOC</div><div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div></div>
14201      <div class="stat-chip"><div class="stat-chip-val" id="chip-most">{most_tested}</div><div class="stat-chip-label">Most Tested Language</div><div class="stat-chip-tip">Language with the highest absolute test function count</div></div>
14202      <div class="stat-chip"><div class="stat-chip-val" id="chip-langs">{langs_with_tests}</div><div class="stat-chip-label">Languages with Tests</div><div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div></div>
14203      <div class="stat-chip"><div class="stat-chip-val" id="chip-cov-pct">{cov_line_pct_str}%</div><div class="stat-chip-label">Line Coverage</div><div class="stat-chip-tip">Overall line coverage across all LCOV-instrumented files (empty if no LCOV data)</div></div>
14204    </div>
14205
14206    <div class="panel" id="viz-panel">
14207      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14208
14209      <div class="chart-box" style="margin-bottom:18px;">
14210        <div class="chart-box-header">
14211          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14212          <div style="display:flex;gap:8px;align-items:center;">
14213            <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">&#8652; Multi-Timeline</button>
14214            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14215          </div>
14216        </div>
14217        <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
14218        <div class="trend-controls-bar">
14219          <label>Y Metric:
14220            <select class="chart-select" id="tm-trend-y">
14221              <option value="test_count" selected>Test Definitions</option>
14222              <option value="code_lines">Code Lines</option>
14223            </select>
14224          </label>
14225          <label>X Axis:
14226            <select class="chart-select" id="tm-trend-x">
14227              <option value="commit" selected>By Commit</option>
14228              <option value="time">By Time</option>
14229            </select>
14230          </label>
14231          <label id="tm-sub-label" style="display:none;">Submodule:
14232            <select class="chart-select" id="tm-trend-sub">
14233              <option value="">All (project total)</option>
14234            </select>
14235          </label>
14236          <label>Chart Size:
14237            <select class="chart-select" id="tm-trend-size">
14238              <option value="200">Compact</option>
14239              <option value="260" selected>Normal</option>
14240              <option value="360">Large</option>
14241            </select>
14242          </label>
14243        </div>
14244        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14245        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14246      </div>
14247
14248      <div class="chart-row">
14249        <div class="chart-box">
14250          <div class="chart-box-header">
14251            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14252            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14253          </div>
14254          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14255          <div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
14256        </div>
14257        <div class="chart-box">
14258          <div class="chart-box-header">
14259            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14260            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14261          </div>
14262          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14263          <div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
14264        </div>
14265      </div>
14266
14267      <div class="chart-row">
14268        <div class="chart-box">
14269          <div class="chart-box-header">
14270            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14271            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14272          </div>
14273          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14274          <div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
14275        </div>
14276        <div class="chart-box" id="suites-chart-box">
14277          <div class="chart-box-header">
14278            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14279            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14280          </div>
14281          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14282          <div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
14283        </div>
14284      </div>
14285
14286      <div class="chart-row">
14287        <div class="chart-box">
14288          <div class="chart-box-title">Test Files Breakdown</div>
14289          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14290          <div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
14291        </div>
14292        <div class="chart-box">
14293          <div class="chart-box-title">Test Composition</div>
14294          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14295          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14296          <div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
14297        </div>
14298      </div>
14299    </div>
14300
14301    <div class="panel">
14302      <h1>Test Metrics</h1>
14303      <p class="muted">Lexical test definition counts across your codebase — how many test functions, test cases, and test decorators were detected per language, and how dense the test coverage is relative to production code.</p>
14304
14305      <div class="section-header">Language Breakdown</div>
14306      {cov_no_data_notice}
14307      <div style="overflow-x:auto;">
14308        <table class="data-table" id="lang-table">
14309          <thead><tr>
14310            <th>Language</th>
14311            <th class="num">Test Fns</th>
14312            <th class="num">Assertions</th>
14313            <th class="num">Suites</th>
14314            <th class="num">Code Lines</th>
14315            <th class="num">Files</th>
14316            <th class="num">Density / 1K</th>
14317            <th>Relative Density</th>
14318          </tr></thead>
14319          <tbody id="lang-tbody"></tbody>
14320        </table>
14321      </div>
14322    </div>
14323
14324    <div class="panel" id="cov-panel" style="display:none;">
14325      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14326      <div class="cov-gauge-row" id="cov-gauges">
14327        <div class="cov-gauge-card">
14328          <div class="cov-gauge-label">Line Coverage</div>
14329          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14330          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14331          <div class="cov-gauge-sub">Lines hit / instrumented</div>
14332          <div class="cov-gauge-tip">Percentage of executable lines exercised by the test suite (lines hit &divide; lines instrumented), aggregated across every file in the LCOV report.</div>
14333        </div>
14334        <div class="cov-gauge-card">
14335          <div class="cov-gauge-label">Function Coverage</div>
14336          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14337          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14338          <div class="cov-gauge-sub">Functions hit / found</div>
14339          <div class="cov-gauge-tip">Percentage of functions called at least once during testing (functions hit &divide; functions found). Shows 0% when the coverage report carries no function-level (FN/FNH) records.</div>
14340        </div>
14341        <div class="cov-gauge-card">
14342          <div class="cov-gauge-label">Branch Coverage</div>
14343          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14344          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14345          <div class="cov-gauge-sub">Branches hit / found</div>
14346          <div class="cov-gauge-tip">Percentage of conditional branches taken during testing (branches hit &divide; branches found). Shows 0% when the coverage report carries no branch-level (BRDA/BRF) records.</div>
14347        </div>
14348      </div>
14349      <div class="chart-row">
14350        <div class="chart-box">
14351          <div class="chart-box-title">Line Coverage % by Language</div>
14352          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14353        </div>
14354        <div class="chart-box">
14355          <div class="chart-box-title">Coverage Tier Distribution</div>
14356          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14357        </div>
14358      </div>
14359
14360      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14361      <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
14362      <div class="cov-file-toolbar">
14363        <div class="cov-filter-tabs" id="cov-filter-tabs">
14364          <button class="cov-tab active" data-tier="all">All</button>
14365          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14366          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
14367          <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
14368          <button class="cov-tab" data-tier="high">High (≥80%)</button>
14369        </div>
14370        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14371      </div>
14372      <div style="overflow-x:auto;">
14373        <table class="data-table" id="cov-file-table">
14374          <thead><tr>
14375            <th>File</th>
14376            <th>Lang</th>
14377            <th class="num">Line %</th>
14378            <th class="num">Lines Hit / Found</th>
14379            <th class="num">Fn %</th>
14380            <th class="num">Fns Hit / Found</th>
14381          </tr></thead>
14382          <tbody id="cov-file-tbody"></tbody>
14383        </table>
14384      </div>
14385      <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
14386      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14387    </div>
14388
14389  </div>
14390
14391  <footer class="site-footer">
14392    local code analysis - metrics, history and reports
14393    &nbsp;·&nbsp; <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14394    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14395    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14396    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14397    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14398  </footer>
14399
14400  <script nonce="{nonce}">
14401  (function() {{
14402    // Theme
14403    var b = document.body;
14404    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14405    var tgl = document.getElementById('theme-toggle');
14406    if (tgl) tgl.addEventListener('click', function() {{
14407      var d = b.classList.toggle('dark-theme');
14408      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14409    }});
14410
14411    // Watermarks
14412    (function() {{
14413      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14414      if (!wms.length) return;
14415      var placed = [];
14416      function tooClose(t,l){{for(var i=0;i<placed.length;i++){{if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}}return false;}}
14417      function pick(lb){{for(var a=0;a<50;a++){{var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){{placed.push([t,l]);return[t,l];}}}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}}
14418      var half=Math.floor(wms.length/2);
14419      wms.forEach(function(img,i){{var pos=pick(i<half),sz=Math.floor(Math.random()*80+110),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.07+0.10).toFixed(2);img.style.width=sz+'px';img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;}});
14420    }})();
14421
14422    // Code particles
14423    (function() {{
14424      var container = document.getElementById('code-particles');
14425      if (!container) return;
14426      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14427      for (var i = 0; i < 36; i++) {{
14428        (function(idx) {{
14429          var el = document.createElement('span');
14430          el.className = 'code-particle';
14431          el.textContent = snippets[idx % snippets.length];
14432          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14433          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14434          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14435          el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
14436          container.appendChild(el);
14437        }})(i);
14438      }}
14439    }})();
14440
14441    // Settings modal
14442    (function() {{
14443      var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
14444      function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
14445      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14446      var btn=document.getElementById('settings-btn');if(!btn)return;
14447      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14448      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
14449      document.body.appendChild(m);
14450      var g=document.getElementById('scheme-grid');
14451      if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
14452      var cl=document.getElementById('settings-close');
14453      btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
14454      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14455      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14456    }})();
14457
14458    // Watched folder picker
14459    (function(){{
14460      window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
14461      document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
14462    }})();
14463    (function() {{
14464      var btn = document.getElementById('add-watched-btn');
14465      if (!btn) return;
14466      btn.addEventListener('click', function() {{
14467        fetch('/pick-directory?kind=reports')
14468          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14469          .then(function(data) {{
14470            if (!data.cancelled && data.selected_path) {{
14471              var form = document.createElement('form');
14472              form.method = 'POST';
14473              form.action = '/watched-dirs/add';
14474              var ri = document.createElement('input');
14475              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14476              var fi = document.createElement('input');
14477              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14478              form.appendChild(ri); form.appendChild(fi);
14479              document.body.appendChild(form);
14480              if (window.__scanOverlay) window.__scanOverlay();
14481              form.submit();
14482            }}
14483          }})
14484          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14485      }});
14486    }})();
14487  }})();
14488  </script>
14489
14490  <script src="/static/chart.js" nonce="{nonce}"></script>
14491  <script nonce="{nonce}">
14492  (function() {{
14493    var SCOPE_DATA = {scope_data_json};
14494    var currentRoot = '__all__';
14495    var currentSub  = '';
14496    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14497    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14498    var ALL_CHARTS = [];
14499    var currentLangTests = [];
14500    var currentTrendPts = [];
14501
14502    function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
14503    function fmtFull(n){{return Number(n).toLocaleString();}}
14504    function isDark(){{return document.body.classList.contains('dark-theme');}}
14505    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14506    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14507    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14508
14509    function makeDlPlugin(fmtFn, anchor) {{
14510      return {{
14511        afterDatasetsDraw: function(chart) {{
14512          var ctx = chart.ctx;
14513          var tc = txtClr();
14514          chart.data.datasets.forEach(function(ds, di) {{
14515            var meta = chart.getDatasetMeta(di);
14516            meta.data.forEach(function(el, idx) {{
14517              var label = fmtFn(ds.data[idx], di, idx);
14518              if (label == null || label === '') return;
14519              ctx.save();
14520              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14521              ctx.fillStyle = tc;
14522              if (anchor === 'top') {{
14523                ctx.textAlign = 'center';
14524                ctx.textBaseline = 'bottom';
14525                ctx.fillText(String(label), el.x, el.y - 5);
14526              }} else {{
14527                ctx.textAlign = 'left';
14528                ctx.textBaseline = 'middle';
14529                ctx.fillText(String(label), el.x + 5, el.y);
14530              }}
14531              ctx.restore();
14532            }});
14533          }});
14534        }}
14535      }};
14536    }}
14537
14538    // Cursor: pointer over chart data, default over empty chart area.
14539    function chartCursor(e, els) {{
14540      var t = e.native && e.native.target;
14541      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14542    }}
14543    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14544
14545    // ── Global bar hover emphasis ──────────────────────────────────────────────
14546    // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14547    // *other* bars does nothing when there is only one). Give every bar chart a
14548    // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14549    // animates via the fast active transition. Applied globally through a plugin so
14550    // it covers all current and future bar charts on the page.
14551    function tmLighten(c, amt) {{
14552      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14553        var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14554        r = Math.round(r + (255 - r) * amt);
14555        g = Math.round(g + (255 - g) * amt);
14556        b = Math.round(b + (255 - b) * amt);
14557        return 'rgb(' + r + ',' + g + ',' + b + ')';
14558      }}
14559      return c;
14560    }}
14561    var tmBarHoverEmphasis = {{
14562      id: 'tmBarHoverEmphasis',
14563      beforeInit: function(chart) {{
14564        if (!chart.config || chart.config.type !== 'bar') return;
14565        (chart.data.datasets || []).forEach(function(ds) {{
14566          var bg = ds.backgroundColor;
14567          if (ds.hoverBackgroundColor == null) {{
14568            ds.hoverBackgroundColor = Array.isArray(bg)
14569              ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14570              : tmLighten(bg, 0.24);
14571          }}
14572          if (ds.hoverBorderColor == null) {{
14573            ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14574          }}
14575          if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14576        }});
14577      }}
14578    }};
14579    Chart.register(tmBarHoverEmphasis);
14580    // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14581    try {{
14582      Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14583      Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14584      Chart.defaults.transitions.active.animation.duration = 260;
14585    }} catch (e) {{}}
14586
14587    // Plugin: draws % labels inside each doughnut slice.
14588    var donutPctPlugin = {{
14589      afterDatasetsDraw: function(chart) {{
14590        var ctx = chart.ctx;
14591        chart.data.datasets.forEach(function(ds, di) {{
14592          var meta = chart.getDatasetMeta(di);
14593          if (meta.hidden) return;
14594          var total = 0;
14595          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14596          if (!total) return;
14597          meta.data.forEach(function(arc, i) {{
14598            if (arc.hidden) return;
14599            var val = ds.data[i] || 0;
14600            var pct = val / total * 100;
14601            if (pct < 3) return;
14602            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14603            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14604            var tx = arc.x + midR * Math.cos(midAngle);
14605            var ty = arc.y + midR * Math.sin(midAngle);
14606            ctx.save();
14607            ctx.textAlign = 'center';
14608            ctx.textBaseline = 'middle';
14609            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14610            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14611            ctx.shadowBlur = 3;
14612            ctx.fillStyle = '#fff';
14613            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14614            ctx.restore();
14615          }});
14616        }});
14617      }}
14618    }};
14619
14620    function makeTmOverlay(title, subtitle, h) {{
14621      var overlay = document.createElement('div');
14622      overlay.className = 'chart-modal-overlay';
14623      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14624      var ch = Math.min(h || 560, maxH);
14625      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14626      overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">&times;</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
14627      document.body.appendChild(overlay);
14628      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14629      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14630      return document.getElementById('tm-modal-canvas');
14631    }}
14632
14633    function getDataset() {{
14634      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14635      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14636      return r;
14637    }}
14638    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14639
14640    function showNoData(id, show) {{
14641      var el = document.getElementById(id);
14642      if (!el) return;
14643      var wrap = el.previousElementSibling;
14644      el.style.display = show ? '' : 'none';
14645      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14646    }}
14647
14648    // Shared hover treatment for every single-series bar/doughnut chart on this page:
14649    // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14650    // treatment used by the language charts on the scan results page.
14651    function tmFadeColor(c) {{
14652      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14653      return c;
14654    }}
14655    function tmApplyFade(chart, activeIdx) {{
14656      var ds = chart.data.datasets[0];
14657      if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14658      if (activeIdx == null) {{
14659        ds.backgroundColor = ds._baseBg.slice();
14660      }} else {{
14661        ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14662          return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14663        }});
14664      }}
14665    }}
14666    function tmFadeHover(e, active, chart) {{
14667      var t = e.native && e.native.target;
14668      if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14669      var idx = active.length ? active[0].index : null;
14670      if (chart._fadeIdx === idx) return;
14671      chart._fadeIdx = idx;
14672      tmApplyFade(chart, idx);
14673      // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14674      // transition (doughnuts keep their own hoverOffset motion regardless).
14675      chart.update('active');
14676    }}
14677    // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14678    function tmDoughnutLegendHover(e, item, leg) {{
14679      var ch = leg.chart;
14680      var t = e.native && e.native.target;
14681      if (t) t.style.cursor = 'pointer';
14682      ch._fadeIdx = item.index;
14683      ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14684      ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14685      tmApplyFade(ch, item.index);
14686      ch.update();
14687    }}
14688    function tmDoughnutLegendLeave(e, item, leg) {{
14689      var ch = leg.chart;
14690      var t = e.native && e.native.target;
14691      if (t) t.style.cursor = 'default';
14692      ch._fadeIdx = null;
14693      ch.setActiveElements([]);
14694      ch.tooltip.setActiveElements([], {{}});
14695      tmApplyFade(ch, null);
14696      ch.update('none');
14697    }}
14698
14699    function renderTestCharts(D) {{
14700      currentLangTests = D || [];
14701      testsChart = destroyChart(testsChart);
14702      densityChart = destroyChart(densityChart);
14703      if (!D || !D.length) {{
14704        showNoData('no-data-tests', true);
14705        showNoData('no-data-density', true);
14706        return;
14707      }}
14708      showNoData('no-data-tests', false);
14709      showNoData('no-data-density', false);
14710      var top15 = D.slice(0, 15);
14711      var canvas1 = document.getElementById('canvas-tests');
14712      if (canvas1) {{
14713        testsChart = new Chart(canvas1, {{
14714          type: 'bar',
14715          data: {{
14716            labels: top15.map(function(d){{ return d.lang; }}),
14717            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14718          }},
14719          options: {{
14720            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14721            layout: {{ padding: {{ right: 64 }} }},
14722            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14723            scales: {{
14724              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14725              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14726            }}
14727          }},
14728          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14729        }});
14730        ALL_CHARTS.push(testsChart);
14731      }}
14732      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14733      var canvas2 = document.getElementById('canvas-density');
14734      if (canvas2) {{
14735        densityChart = new Chart(canvas2, {{
14736          type: 'bar',
14737          data: {{
14738            labels: topD.map(function(d){{ return d.lang; }}),
14739            datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14740          }},
14741          options: {{
14742            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14743            layout: {{ padding: {{ right: 64 }} }},
14744            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14745            scales: {{
14746              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14747              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14748            }}
14749          }},
14750          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14751        }});
14752        ALL_CHARTS.push(densityChart);
14753      }}
14754    }}
14755
14756    function renderAssertionsChart(D) {{
14757      assertionsChart = destroyChart(assertionsChart);
14758      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14759      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14760      var canvas = document.getElementById('canvas-assertions');
14761      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14762      showNoData('no-data-assertions', false);
14763      assertionsChart = new Chart(canvas, {{
14764        type: 'bar',
14765        data: {{
14766          labels: top15.map(function(d){{ return d.lang; }}),
14767          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14768        }},
14769        options: {{
14770          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14771          layout: {{ padding: {{ right: 64 }} }},
14772          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14773          scales: {{
14774            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14775            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14776          }}
14777        }},
14778        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14779      }});
14780      ALL_CHARTS.push(assertionsChart);
14781    }}
14782
14783    function renderSuitesChart(D) {{
14784      suitesChart = destroyChart(suitesChart);
14785      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14786      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14787      var canvas = document.getElementById('canvas-suites');
14788      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14789      showNoData('no-data-suites', false);
14790      suitesChart = new Chart(canvas, {{
14791        type: 'bar',
14792        data: {{
14793          labels: top15.map(function(d){{ return d.lang; }}),
14794          datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14795        }},
14796        options: {{
14797          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14798          layout: {{ padding: {{ right: 64 }} }},
14799          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14800          scales: {{
14801            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14802            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14803          }}
14804        }},
14805        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14806      }});
14807      ALL_CHARTS.push(suitesChart);
14808    }}
14809
14810    function renderFilesChart(totals) {{
14811      filesChart = destroyChart(filesChart);
14812      var canvas = document.getElementById('canvas-files');
14813      if (!canvas) return;
14814      var testF = totals.test_files || 0;
14815      var totalF = totals.total_files || 0;
14816      var nonTest = Math.max(0, totalF - testF);
14817      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14818      showNoData('no-data-files', false);
14819      var dark = isDark();
14820      filesChart = new Chart(canvas, {{
14821        type: 'doughnut',
14822        data: {{
14823          labels: ['Test Files', 'Non-Test Files'],
14824          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14825        }},
14826        options: {{
14827          responsive: true, maintainAspectRatio: false, cutout: '62%',
14828          onHover: tmFadeHover,
14829          plugins: {{
14830            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14831              generateLabels: function(chart) {{
14832                var ds = chart.data.datasets[0];
14833                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14834                return chart.data.labels.map(function(lbl, i) {{
14835                  var val = ds.data[i] || 0;
14836                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14837                  return {{
14838                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14839                    fillStyle: ds.backgroundColor[i],
14840                    strokeStyle: ds.borderColor,
14841                    lineWidth: ds.borderWidth,
14842                    hidden: false,
14843                    index: i,
14844                    datasetIndex: 0
14845                  }};
14846                }});
14847              }}
14848            }},
14849              onHover: tmDoughnutLegendHover,
14850              onLeave: tmDoughnutLegendLeave
14851            }},
14852            tooltip: {{ callbacks: {{ label: function(ctx) {{
14853              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14854              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14855            }} }} }}
14856          }}
14857        }},
14858        plugins: [donutPctPlugin]
14859      }});
14860      ALL_CHARTS.push(filesChart);
14861    }}
14862
14863    function renderCompositionChart(totals) {{
14864      compositionChart = destroyChart(compositionChart);
14865      var canvas = document.getElementById('canvas-composition');
14866      if (!canvas) return;
14867      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14868      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14869      showNoData('no-data-composition', false);
14870      compositionChart = new Chart(canvas, {{
14871        type: 'bar',
14872        data: {{
14873          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14874          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14875        }},
14876        options: {{
14877          responsive: true, maintainAspectRatio: false,
14878          onHover: tmFadeHover,
14879          layout: {{ padding: {{ top: 22 }} }},
14880          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14881          scales: {{
14882            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14883            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14884          }}
14885        }},
14886        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14887      }});
14888      ALL_CHARTS.push(compositionChart);
14889    }}
14890
14891    function renderCovCharts(covD, tiers) {{
14892      covChart = destroyChart(covChart);
14893      tierChart = destroyChart(tierChart);
14894      var covCanvas = document.getElementById('canvas-cov');
14895      if (covCanvas && covD && covD.length) {{
14896        covChart = new Chart(covCanvas, {{
14897          type: 'bar',
14898          data: {{
14899            labels: covD.map(function(d){{ return d.lang; }}),
14900            datasets: [{{ label: 'Line Coverage %', data: covD.map(function(d){{ return d.pct; }}), backgroundColor: covD.map(function(d){{ return d.pct >= 80 ? '#2A6846' : d.pct >= 50 ? '#D4A017' : '#B23030'; }}), borderRadius: 4 }}]
14901          }},
14902          options: {{
14903            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14904            layout: {{ padding: {{ right: 52 }} }},
14905            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14906            scales: {{
14907              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14908              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14909            }}
14910          }},
14911          plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
14912        }});
14913        ALL_CHARTS.push(covChart);
14914      }}
14915      var tierCanvas = document.getElementById('canvas-cov-tiers');
14916      if (tierCanvas && tiers) {{
14917        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14918        tierChart = new Chart(tierCanvas, {{
14919          type: 'doughnut',
14920          data: {{
14921            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14922            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14923          }},
14924          options: {{
14925            responsive: true, maintainAspectRatio: false, cutout: '62%',
14926            onHover: tmFadeHover,
14927            plugins: {{
14928              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14929                onHover: tmDoughnutLegendHover,
14930                onLeave: tmDoughnutLegendLeave
14931              }},
14932              tooltip: {{ callbacks: {{ label: function(ctx) {{
14933                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14934                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14935              }} }} }}
14936            }}
14937          }},
14938          plugins: [donutPctPlugin]
14939        }});
14940        ALL_CHARTS.push(tierChart);
14941      }}
14942    }}
14943
14944    function buildLangTable(D) {{
14945      var tbody = document.getElementById('lang-tbody');
14946      if (!tbody) return;
14947      if (!D || !D.length) {{
14948        tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
14949        return;
14950      }}
14951      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14952      tbody.innerHTML = D.map(function(d) {{
14953        var barW = Math.round(d.density / maxDensity * 120);
14954        return '<tr>' +
14955          '<td><strong>' + d.lang + '</strong></td>' +
14956          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14957          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14958          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14959          '<td class="num">' + fmtFull(d.code) + '</td>' +
14960          '<td class="num">' + fmtFull(d.files) + '</td>' +
14961          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14962          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14963          '</tr>';
14964      }}).join('');
14965    }}
14966
14967    var covFileData = [];
14968    var covFileTier = 'all';
14969    var covFileSearch = '';
14970
14971    function pctBadge(pct) {{
14972      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14973      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14974      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14975    }}
14976
14977    function buildCovFileTable() {{
14978      var tbody = document.getElementById('cov-file-tbody');
14979      var empty = document.getElementById('cov-file-empty');
14980      var count = document.getElementById('cov-file-count');
14981      if (!tbody) return;
14982      var srch = covFileSearch.toLowerCase();
14983      var filtered = covFileData.filter(function(f) {{
14984        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14985        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14986        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14987        if (covFileTier === 'high' && f.line_pct < 80) return false;
14988        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14989        return true;
14990      }});
14991      if (!filtered.length) {{
14992        tbody.innerHTML = '';
14993        if (empty) empty.style.display = '';
14994        if (count) count.textContent = '';
14995        return;
14996      }}
14997      if (empty) empty.style.display = 'none';
14998      var shown = Math.min(filtered.length, 500);
14999      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15000      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15001        var fnCol = f.fn_pct < 0
15002          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15003          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15004        return '<tr>' +
15005          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
15006          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15007          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15008          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15009          fnCol +
15010          '</tr>';
15011      }}).join('');
15012    }}
15013
15014    (function() {{
15015      var tabs = document.getElementById('cov-filter-tabs');
15016      if (tabs) {{
15017        tabs.addEventListener('click', function(e) {{
15018          var btn = e.target.closest('.cov-tab');
15019          if (!btn) return;
15020          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15021          btn.classList.add('active');
15022          covFileTier = btn.getAttribute('data-tier');
15023          buildCovFileTable();
15024        }});
15025      }}
15026      var srch = document.getElementById('cov-file-search');
15027      if (srch) {{
15028        srch.addEventListener('input', function() {{
15029          covFileSearch = this.value;
15030          buildCovFileTable();
15031        }});
15032      }}
15033    }})();
15034
15035    function updateCovGauges(t) {{
15036      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15037      var el;
15038      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15039      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15040      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15041      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15042      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15043      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15044    }}
15045
15046    function applyScope() {{
15047      var d = getDataset();
15048      var t = d.totals;
15049      var el;
15050      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15051      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15052      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15053      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15054      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15055      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15056      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15057      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15058      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15059      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15060      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15061      renderTestCharts(d.lang_tests);
15062      renderAssertionsChart(d.lang_tests);
15063      renderSuitesChart(d.lang_tests);
15064      renderFilesChart(t);
15065      renderCompositionChart(t);
15066      buildLangTable(d.lang_tests);
15067      var covPanel = document.getElementById('cov-panel');
15068      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15069      if (d.has_coverage) {{
15070        renderCovCharts(d.cov, d.cov_tiers);
15071        updateCovGauges(t);
15072        covFileData = d.file_cov || [];
15073        covFileTier = 'all';
15074        covFileSearch = '';
15075        var tabs = document.getElementById('cov-filter-tabs');
15076        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15077        var srch = document.getElementById('cov-file-search');
15078        if (srch) srch.value = '';
15079        buildCovFileTable();
15080      }}
15081      loadTrend();
15082    }}
15083
15084    // Populate scope-root-sel from SCOPE_DATA keys
15085    (function() {{
15086      var sel = document.getElementById('scope-root-sel');
15087      if (!sel) return;
15088      Object.keys(SCOPE_DATA).forEach(function(k) {{
15089        if (k === '__all__') return;
15090        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15091      }});
15092    }})();
15093
15094    document.getElementById('scope-root-sel').addEventListener('change', function() {{
15095      currentRoot = this.value;
15096      currentSub = '';
15097      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15098      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15099      var subWrap = document.getElementById('scope-sub-wrap');
15100      var subSel  = document.getElementById('scope-sub-sel');
15101      subSel.innerHTML = '<option value="">Entire project</option>';
15102      if (subNames.length) {{
15103        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15104        subWrap.style.display = 'flex';
15105      }} else {{
15106        subWrap.style.display = 'none';
15107      }}
15108      applyScope();
15109    }});
15110
15111    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15112      currentSub = this.value;
15113      applyScope();
15114    }});
15115
15116    var allTrendData = [];
15117
15118    var TM_Y_META = {{
15119      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15120      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
15121    }};
15122
15123    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15124    function hexRgb(hex) {{
15125      var h = String(hex).replace('#', '');
15126      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15127      var n = parseInt(h, 16);
15128      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15129    }}
15130    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15131    // tint at the top to transparent at the bottom (no flat solid block).
15132    function tmTrendGradient(ctx2, chartArea, color) {{
15133      var rgb = hexRgb(color);
15134      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15135      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
15136      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15137      g.addColorStop(1,   'rgba(' + rgb + ',0)');
15138      return g;
15139    }}
15140
15141    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15142    // so linear interpolation between adjacent points matches the drawn line).
15143    function tmLineYAt(chart, px) {{
15144      var meta = chart.getDatasetMeta(0);
15145      if (!meta || !meta.data || !meta.data.length) return null;
15146      var d = meta.data;
15147      if (px <= d[0].x) return d[0].y;
15148      for (var i = 1; i < d.length; i++) {{
15149        if (px <= d[i].x) {{
15150          var span = d[i].x - d[i - 1].x;
15151          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15152          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15153        }}
15154      }}
15155      return d[d.length - 1].y;
15156    }}
15157
15158    // Plugin: only show the tooltip / finger cursor when the pointer is over the
15159    // gradient fill (inside the plot and at/below the line) — never in the empty
15160    // space above the line. Outside the fill we retype the event as 'mouseout' so
15161    // the core interaction dismisses any active tooltip on its own.
15162    var tmFillGuard = {{
15163      id: 'tmFillGuard',
15164      beforeEvent: function(chart, args) {{
15165        var e = args.event;
15166        if (!e || e.type !== 'mousemove') return;
15167        var ca = chart.chartArea;
15168        if (!ca) return;
15169        var inFill = false;
15170        if (e.x >= ca.left && e.x <= ca.right) {{
15171          var ly = tmLineYAt(chart, e.x);
15172          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15173        }}
15174        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15175        if (!inFill) {{ e.type = 'mouseout'; }}
15176      }}
15177    }};
15178
15179    // Single source of truth for the test-metrics trend chart config so the inline
15180    // chart and the Full View modal render identically (straight segments, gradient
15181    // fill, white-ringed points, gradient-only interactivity).
15182    function buildTmTrendConfig(pts, ctrl, meta) {{
15183      return {{
15184        type: 'line',
15185        data: {{
15186          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15187          datasets: [{{
15188            label: meta.label,
15189            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15190            borderColor: meta.color,
15191            borderWidth: 2.5,
15192            backgroundColor: function(context) {{
15193              var ca = context.chart.chartArea;
15194              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15195              return tmTrendGradient(context.chart.ctx, ca, meta.color);
15196            }},
15197            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15198            pointBorderColor: '#fff',
15199            pointBorderWidth: 2,
15200            pointRadius: 6,
15201            pointHoverRadius: 9,
15202            pointHoverBorderWidth: 2.5,
15203            fill: true, tension: 0
15204          }}]
15205        }},
15206        options: {{
15207          responsive: true, maintainAspectRatio: false,
15208          layout: {{ padding: {{ top: 22 }} }},
15209          interaction: {{ mode: 'index', intersect: false }},
15210          plugins: {{
15211            legend: {{ display: false }},
15212            tooltip: {{
15213              mode: 'index', intersect: false,
15214              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15215            }}
15216          }},
15217          scales: {{
15218            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15219            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15220          }}
15221        }},
15222        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15223      }};
15224    }}
15225
15226    function getTrendControls() {{
15227      var ySel    = document.getElementById('tm-trend-y');
15228      var xSel    = document.getElementById('tm-trend-x');
15229      var sizeSel = document.getElementById('tm-trend-size');
15230      var subSel  = document.getElementById('tm-trend-sub');
15231      return {{
15232        yKey:    ySel    ? ySel.value    : 'test_count',
15233        xMode:   xSel    ? xSel.value    : 'commit',
15234        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
15235        submod:  subSel  ? subSel.value  : ''
15236      }};
15237    }}
15238
15239    function makeTrendLabel(d, xMode) {{
15240      if (xMode === 'commit') {{
15241        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15242      }}
15243      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15244    }}
15245
15246    function buildTrend(data) {{
15247      allTrendData = data || [];
15248      renderTrend();
15249    }}
15250
15251    function renderTrend() {{
15252      var data = allTrendData;
15253      var ctrl = getTrendControls();
15254      var trendCanvas = document.getElementById('canvas-trend');
15255      var trendWrap   = document.getElementById('trend-canvas-wrap');
15256      var trendEmpty  = document.getElementById('trend-empty');
15257
15258      // Apply chart size
15259      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15260
15261      // Filter by submodule if selected (entries from project_label match)
15262      var pts = data.slice().reverse();
15263      if (ctrl.submod) {{
15264        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15265      }}
15266
15267      currentTrendPts = pts;
15268
15269      if (!pts.length) {{
15270        if (trendCanvas) trendCanvas.style.display = 'none';
15271        if (trendEmpty) trendEmpty.style.display = '';
15272        return;
15273      }}
15274      if (trendCanvas) trendCanvas.style.display = '';
15275      if (trendEmpty) trendEmpty.style.display = 'none';
15276
15277      trendChart = destroyChart(trendChart);
15278      if (!trendCanvas) return;
15279
15280      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15281
15282      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15283      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15284      ALL_CHARTS.push(trendChart);
15285
15286      // Populate submodule selector from unique project_labels
15287      var subSel = document.getElementById('tm-trend-sub');
15288      var subLabel = document.getElementById('tm-sub-label');
15289      if (subSel && data.length) {{
15290        var projects = [];
15291        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15292        if (projects.length > 1) {{
15293          var curVal = subSel.value;
15294          subSel.innerHTML = '<option value="">All (project total)</option>';
15295          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15296          if (subLabel) subLabel.style.display = '';
15297        }} else {{
15298          if (subLabel) subLabel.style.display = 'none';
15299        }}
15300      }}
15301    }}
15302
15303    // ── Full View expand buttons ──────────────────────────────────────────────
15304    (function() {{
15305      var btn = document.getElementById('tests-expand-btn');
15306      if (!btn) return;
15307      btn.addEventListener('click', function() {{
15308        var D = currentLangTests;
15309        if (!D || !D.length) return;
15310        var top15 = D.slice(0, 15);
15311        var h = Math.max(320, top15.length * 36 + 80);
15312        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15313        if (!canvas) return;
15314        new Chart(canvas, {{
15315          type: 'bar',
15316          data: {{
15317            labels: top15.map(function(d){{ return d.lang; }}),
15318            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15319          }},
15320          options: {{
15321            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15322            layout: {{ padding: {{ right: 72 }} }},
15323            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15324            scales: {{
15325              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15326              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15327            }}
15328          }},
15329          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15330        }});
15331      }});
15332    }})();
15333
15334    (function() {{
15335      var btn = document.getElementById('density-expand-btn');
15336      if (!btn) return;
15337      btn.addEventListener('click', function() {{
15338        var D = currentLangTests;
15339        if (!D || !D.length) return;
15340        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15341        var h = Math.max(320, topD.length * 36 + 80);
15342        var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15343        if (!canvas) return;
15344        new Chart(canvas, {{
15345          type: 'bar',
15346          data: {{
15347            labels: topD.map(function(d){{ return d.lang; }}),
15348            datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
15349          }},
15350          options: {{
15351            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15352            layout: {{ padding: {{ right: 72 }} }},
15353            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15354            scales: {{
15355              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15356              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15357            }}
15358          }},
15359          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15360        }});
15361      }});
15362    }})();
15363
15364    (function() {{
15365      var btn = document.getElementById('trend-expand-btn');
15366      if (!btn) return;
15367      btn.addEventListener('click', function() {{
15368        var pts = currentTrendPts;
15369        if (!pts || !pts.length) return;
15370        var ctrl = getTrendControls();
15371        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15372        var title = meta.label + ' Trend \u2014 Full View';
15373        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15374        if (!canvas) return;
15375        // Reuse the exact inline-chart config so Full View matches the default view
15376        // (straight segments + gradient-only interactivity), just larger.
15377        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15378      }});
15379    }})();
15380
15381    (function() {{
15382      var btn = document.getElementById('assertions-expand-btn');
15383      if (!btn) return;
15384      btn.addEventListener('click', function() {{
15385        var D = currentLangTests;
15386        if (!D || !D.length) return;
15387        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15388        if (!top15.length) return;
15389        var h = Math.max(320, top15.length * 36 + 80);
15390        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15391        if (!canvas) return;
15392        new Chart(canvas, {{
15393          type: 'bar',
15394          data: {{
15395            labels: top15.map(function(d){{ return d.lang; }}),
15396            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15397          }},
15398          options: {{
15399            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15400            layout: {{ padding: {{ right: 72 }} }},
15401            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15402            scales: {{
15403              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15404              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15405            }}
15406          }},
15407          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15408        }});
15409      }});
15410    }})();
15411
15412    (function() {{
15413      var btn = document.getElementById('suites-expand-btn');
15414      if (!btn) return;
15415      btn.addEventListener('click', function() {{
15416        var D = currentLangTests;
15417        if (!D || !D.length) return;
15418        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15419        if (!top15.length) return;
15420        var h = Math.max(320, top15.length * 36 + 80);
15421        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15422        if (!canvas) return;
15423        new Chart(canvas, {{
15424          type: 'bar',
15425          data: {{
15426            labels: top15.map(function(d){{ return d.lang; }}),
15427            datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
15428          }},
15429          options: {{
15430            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15431            layout: {{ padding: {{ right: 72 }} }},
15432            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15433            scales: {{
15434              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15435              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15436            }}
15437          }},
15438          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15439        }});
15440      }});
15441    }})();
15442
15443    // Wire trend control selectors — re-render without re-fetching
15444    (function() {{
15445      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15446        var el = document.getElementById(id);
15447        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15448      }});
15449    }})();
15450
15451    function loadTrend() {{
15452      var url = '/api/metrics/history?limit=100';
15453      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15454      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15455        buildTrend(data);
15456        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15457        var btn = document.getElementById('multi-compare-trend-btn');
15458        if (btn) {{
15459          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15460          if (ids.length >= 2) {{
15461            btn.style.display = '';
15462            btn.onclick = function() {{
15463              // Reverse so oldest first (API returns newest first).
15464              var sorted = ids.slice().reverse();
15465              if (sorted.length === 2) {{
15466                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15467              }} else {{
15468                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15469              }}
15470            }};
15471          }} else {{
15472            btn.style.display = 'none';
15473          }}
15474        }}
15475      }}).catch(function(){{
15476        var trendEmpty = document.getElementById('trend-empty');
15477        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15478      }});
15479    }}
15480
15481    // Re-render charts on theme toggle
15482    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15483      setTimeout(function() {{
15484        ALL_CHARTS.forEach(function(c) {{
15485          if (c && c.options && c.options.scales) {{
15486            Object.values(c.options.scales).forEach(function(ax) {{
15487              if (ax.grid) ax.grid.color = clr();
15488              if (ax.ticks) ax.ticks.color = txtClr();
15489            }});
15490            c.update();
15491          }}
15492        }});
15493      }}, 80);
15494    }});
15495
15496    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15497    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15498    function tmExportMeta() {{
15499      var sel = document.getElementById('scope-sel');
15500      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15501      if (!proj || proj === '__all__') proj = 'All projects';
15502      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15503      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15504      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15505      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15506      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15507    }}
15508
15509    function exportTmXLSX() {{
15510      var D = currentLangTests;
15511      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15512      var t = tmExportMeta();
15513      function s2b(s) {{ return new TextEncoder().encode(s); }}
15514      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
15515      function col2l(n) {{ var s=''; while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}} return s; }}
15516      function crc32(d) {{
15517        if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
15518        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15519      }}
15520      // Store all cells as strings so Excel left-aligns uniformly.
15521      function cs(addr, val, bold) {{
15522        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15523      }}
15524      // Build an Excel Table XML definition for a given sheet range and columns.
15525      function makeTableXml(tblId, name, ref, cols) {{
15526        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15527        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15528        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15529        x+='<autoFilter ref="'+ref+'"/>';
15530        x+='<tableColumns count="'+cols.length+'">';
15531        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15532        x+='</tableColumns>';
15533        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15534        return x+'</table>';
15535      }}
15536      // Worksheet XML with optional Excel Table part reference.
15537      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15538        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15539        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15540        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15541        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15542        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15543        rows.forEach(function(row,ri){{var rn=ri+2;x+='<row r="'+rn+'">';row.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,false);}});x+='</row>';}});
15544        if(totRow){{var rn=rows.length+2;x+='<row r="'+rn+'">';totRow.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,true);}});x+='</row>';}}
15545        x+='</sheetData>';
15546        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15547        return x+'</worksheet>';
15548      }}
15549
15550      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15551      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15552      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15553      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15554      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15555      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15556
15557      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15558      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15559      var sheets=[];
15560
15561      // Sheet: Summary
15562      var sumHdr=['Metric','Value'];
15563      var sumRows=[
15564        ['Project / Scope', t.proj],
15565        ['Export Date', t.full],
15566        ['Test Functions', Number(totTests).toLocaleString()],
15567        ['Assertions', Number(totAssert).toLocaleString()],
15568        ['Test Suites', Number(totSuites).toLocaleString()],
15569        ['Languages with Tests', String(D.length)],
15570        ['Total Code Lines', Number(totCode).toLocaleString()],
15571        ['Average Density (per 1K)', String(avgDensity)],
15572      ];
15573      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15574
15575      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15576      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15577      var langRows=D.map(function(d){{return[d.lang,Number(d.tests).toLocaleString(),Number(d.assertions||0).toLocaleString(),Number(d.suites||0).toLocaleString(),Number(d.code).toLocaleString(),Number(d.files).toLocaleString(),Number(d.density).toFixed(2)];}});
15578      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15579      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15580
15581      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15582      var covDs=(typeof getDataset==='function')?getDataset():null;
15583      if(covDs&&covDs.has_coverage){{
15584        var covT=covDs.totals||{{}};
15585        var covSumHdr=['Metric','Value'];
15586        var covSumRows=[
15587          ['Line Coverage', (covT.cov_line||'0')+'%'],
15588          ['Function Coverage', (covT.cov_fn||'0')+'%'],
15589          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15590        ];
15591        if(covDs.cov_tiers){{
15592          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15593          covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
15594          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15595        }}
15596        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15597
15598        if(covDs.cov&&covDs.cov.length){{
15599          var covLangHdr=['Language','Line Coverage %'];
15600          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15601          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15602        }}
15603        if(covFileData&&covFileData.length){{
15604          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15605          var covFileRows=covFileData.map(function(f){{
15606            var noFn=f.fn_pct<0;
15607            return[f.rel,f.lang,Number(f.line_pct).toFixed(1),String(f.lhit),String(f.lfound),noFn?'—':Number(f.fn_pct).toFixed(1),noFn?'—':String(f.fhit),noFn?'—':String(f.ffound)];
15608          }});
15609          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15610        }}
15611      }}
15612
15613      var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>';
15614      var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
15615
15616      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15617      var files=[];
15618      var ctOverrides='', wbSheetTags='', wbRelTags='';
15619      sheets.forEach(function(sh,i){{
15620        var n=i+1;
15621        var lastCol=col2l(sh.hdr.length);
15622        var ref='A1:'+lastCol+(sh.rows.length+1);
15623        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15624        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15625        var shRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table'+n+'.xml"/></Relationships>';
15626        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15627        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15628        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15629        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15630        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15631        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15632        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15633      }});
15634      var styleRid='rId'+(sheets.length+1);
15635      var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'+ctOverrides+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15636      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="'+styleRid+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'+wbRelTags+'</Relationships>';
15637      var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>'+wbSheetTags+'</sheets></workbook>';
15638      files.unshift(
15639        {{name:'[Content_Types].xml',data:s2b(ct)}},
15640        {{name:'_rels/.rels',data:s2b(dotrels)}},
15641        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15642        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15643        {{name:'xl/styles.xml',data:s2b(styl)}}
15644      );
15645      var parts=[],offsets=[],total=0;
15646      files.forEach(function(f){{offsets.push(total);var nb=s2b(f.name),crc=crc32(f.data);var h=new DataView(new ArrayBuffer(30+nb.length));h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);h.setUint16(26,nb.length,true);h.setUint16(28,0,true);for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);parts.push(new Uint8Array(h.buffer));parts.push(f.data);total+=30+nb.length+f.data.length;}});
15647      var cdStart=total;files.forEach(function(f,fi){{var nb=s2b(f.name),crc=crc32(f.data);var cd=new DataView(new ArrayBuffer(46+nb.length));cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;}});
15648      var cdSz=total-cdStart;var eocd=new DataView(new ArrayBuffer(22));eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);parts.push(new Uint8Array(eocd.buffer));
15649      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);var out=new Uint8Array(sz);var off=0;parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15650      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15651      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15652      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15653      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15654    }}
15655
15656    function exportTmPNG() {{
15657      // Map canvas IDs to display titles
15658      var CHART_TITLES = {{
15659        'canvas-trend':       'TEST COUNT TREND',
15660        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15661        'canvas-density':     'TEST DENSITY (per 1,000 code lines)',
15662        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15663        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15664        'canvas-files':       'TEST FILES BREAKDOWN',
15665        'canvas-composition': 'TEST COMPOSITION',
15666        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
15667        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
15668      }};
15669      // Coverage canvases are only appended when the LCOV panel is visible (has data).
15670      var covPanelEl=document.getElementById('cov-panel');
15671      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15672      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15673      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15674      // Include only charts that actually rendered data. A "no data" chart has its
15675      // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15676      // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15677      function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15678      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15679      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15680      var t=tmExportMeta();
15681      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15682      var trendCanvas=document.getElementById('canvas-trend');
15683      var hasTrend=chartHasData(trendCanvas);
15684      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15685      var TOTAL_W=COLW*2+GAP;
15686      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15687      TREND_H=Math.min(Math.max(200,TREND_H),340);
15688      // Per-row chart heights (2-col grid)
15689      var gridRows=Math.ceil(gridCanvases.length/2);
15690      var rowHeights=[];
15691      for(var ri=0;ri<gridRows;ri++){{
15692        var rh=240;
15693        for(var ci=0;ci<2;ci++){{
15694          var cv=gridCanvases[ri*2+ci];
15695          if(cv&&cv.width>0){{
15696            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15697            rh=Math.max(rh,Math.min(420,nat));
15698          }}
15699        }}
15700        rowHeights.push(rh);
15701      }}
15702      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15703      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15704      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15705      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15706      var ctx=out.getContext('2d');
15707      var cs2=getComputedStyle(document.body);
15708      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15709      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15710      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15711
15712      // Background
15713      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15714
15715      // Orange header block
15716      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15717      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15718      ctx.fillText('Test Metrics — '+t.proj,22,42);
15719      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15720      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15721      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15722
15723      // Helper: draw a section title label
15724      function drawTitle(label, x, y, w) {{
15725        ctx.save();
15726        ctx.fillStyle=oxide;
15727        ctx.font='700 11px '+TM_FONT;
15728        ctx.textBaseline='middle';
15729        ctx.textAlign='left';
15730        ctx.letterSpacing='0.07em';
15731        ctx.fillText(label, x+2, y+TITLE_H/2);
15732        // Underline
15733        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15734        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15735        ctx.globalAlpha=1;
15736        ctx.restore();
15737      }}
15738
15739      var yOff=HEADER_H;
15740
15741      // Trend chart (full width)
15742      if(hasTrend){{
15743        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15744        yOff+=TITLE_H;
15745        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15746        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15747        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15748        ctx.drawImage(surf,0,yOff);
15749        yOff+=TREND_H+ROW_PAD;
15750      }}
15751
15752      // Grid charts (2-col), each cell gets title + chart
15753      for(var gi=0;gi<gridRows;gi++){{
15754        var rh2=rowHeights[gi];
15755        // Draw row titles and charts
15756        for(var gci=0;gci<2;gci++){{
15757          var idx2=gi*2+gci;
15758          if(idx2>=gridCanvases.length)continue;
15759          var gcv=gridCanvases[idx2];
15760          var gx=gci*(COLW+GAP);
15761          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15762        }}
15763        yOff+=TITLE_H;
15764        for(var gci2=0;gci2<2;gci2++){{
15765          var idx3=gi*2+gci2;
15766          if(idx3>=gridCanvases.length)continue;
15767          var gcv2=gridCanvases[idx3];
15768          var gx2=gci2*(COLW+GAP);
15769          var natW=gcv2.width,natH=gcv2.height;
15770          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15771          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15772          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15773          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15774          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15775          ctx.drawImage(surf2,gx2,yOff);
15776        }}
15777        yOff+=rh2+ROW_PAD;
15778      }}
15779
15780      // Dark footer
15781      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15782      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15783      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15784
15785      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15786      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15787    }}
15788
15789    function exportTmPDF(ev) {{
15790      var D=currentLangTests;
15791      var t=tmExportMeta();
15792      var strips=document.querySelectorAll('.summary-strip');
15793      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15794      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15795      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15796      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15797      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15798      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15799      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15800      var rows='';
15801      (D||[]).forEach(function(d){{
15802        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15803          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15804          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15805          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15806          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15807          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15808          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15809      }});
15810      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15811        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15812        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15813        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15814        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15815        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15816        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15817      var tableHtml='<table><thead><tr><th>Language</th><th class="n">Test Fns</th><th class="n">Assertions</th><th class="n">Suites</th><th class="n">Code Lines</th><th class="n">Files</th><th class="n">Density/1K</th></tr></thead><tbody>'+rows+totRow+'</tbody></table>';
15818      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15819        +'html,body{{height:100%;margin:0;}}'
15820        +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;display:flex;flex-direction:column;min-height:100vh;}}'
15821        +'.rep-header{{background:#C45C10;color:#fff;padding:18px 32px 16px;display:flex;justify-content:space-between;align-items:flex-start;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15822        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15823        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15824        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15825        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15826        +'.rep-body{{padding:20px 32px;flex:1;}}'
15827        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15828        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15829        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15830        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15831        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15832        +'.section-hdr{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:#C45C10;margin:16px 0 8px;border-bottom:2px solid #C45C10;padding-bottom:4px;}}'
15833        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15834        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15835        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15836        +'.n{{text-align:right;}}'
15837        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15838        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15839        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15840        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15841        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15842        +'.rep-footer{{background:#43342d;color:rgba(255,255,255,0.75);padding:10px 32px;font-size:10px;text-align:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15843        +'</style>';
15844      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15845      var covDs=(typeof getDataset==='function')?getDataset():null;
15846      var covHtml='';
15847      if(covDs&&covDs.has_coverage){{
15848        var covT=covDs.totals||{{}};
15849        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15850          +'<div class="cov-strip">'
15851          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15852          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15853          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15854          +'</div>';
15855        if(covFileData&&covFileData.length){{
15856          var cfrows='';
15857          covFileData.forEach(function(f){{
15858            var noFn=f.fn_pct<0;
15859            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15860              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15861              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15862              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15863              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15864          }});
15865          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15866            +'<table><thead><tr><th>File</th><th>Lang</th><th class="n">Line %</th><th class="n">Lines Hit / Found</th><th class="n">Fn %</th><th class="n">Fns Hit / Found</th></tr></thead><tbody>'+cfrows+'</tbody></table>';
15867        }}
15868      }}
15869      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15870        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15871        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15872        +'<div class="rep-body">'+statsHtml
15873        +'<div class="section-hdr">Language Breakdown</div>'
15874        +tableHtml+covHtml+'</div>'
15875        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15876        +'</body></html>';
15877      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15878      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15879      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15880    }}
15881
15882    (function() {{
15883      // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
15884      // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
15885      var xBtn=document.getElementById('tm-export-xlsx-btn');
15886      var pngBtn=document.getElementById('tm-export-png-btn');
15887      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15888      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15889      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15890      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15891    }})();
15892
15893    applyScope();
15894  }})();
15895  </script>
15896  <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
15897  {toast_assets}
15898</body>
15899</html>"#,
15900    );
15901    (
15902        [(axum::http::header::CACHE_CONTROL, "no-store")],
15903        Html(html),
15904    )
15905        .into_response()
15906}
15907
15908// ── Embeddable widget ─────────────────────────────────────────────────────────
15909// Protected. Returns a self-contained HTML page suitable for iframing inside
15910// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15911//
15912// GET /embed/summary?run_id=<uuid>&theme=dark
15913
15914#[derive(Deserialize)]
15915struct EmbedQuery {
15916    run_id: Option<String>,
15917    theme: Option<String>,
15918}
15919
15920async fn embed_handler(
15921    State(state): State<AppState>,
15922    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15923    Query(query): Query<EmbedQuery>,
15924) -> Response {
15925    let entry = {
15926        let reg = state.registry.lock().await;
15927        query.run_id.as_ref().map_or_else(
15928            || reg.entries.first().cloned(),
15929            |id| reg.find_by_run_id(id).cloned(),
15930        )
15931    };
15932
15933    let Some(entry) = entry else {
15934        return Html(
15935            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15936                .to_string(),
15937        )
15938        .into_response();
15939    };
15940
15941    let dark = query.theme.as_deref() == Some("dark");
15942    let languages: Vec<(String, u64, u64)> = entry
15943        .json_path
15944        .as_ref()
15945        .and_then(|p| read_json(p).ok())
15946        .map(|run| {
15947            run.totals_by_language
15948                .iter()
15949                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15950                .collect()
15951        })
15952        .unwrap_or_default();
15953
15954    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15955}
15956
15957fn render_embed_widget(
15958    entry: &RegistryEntry,
15959    languages: &[(String, u64, u64)],
15960    dark: bool,
15961    csp_nonce: &str,
15962) -> String {
15963    let s = &entry.summary;
15964    let total = s.code_lines + s.comment_lines + s.blank_lines;
15965    let code_pct = s
15966        .code_lines
15967        .checked_mul(100)
15968        .and_then(|n| n.checked_div(total))
15969        .unwrap_or(0);
15970
15971    let (bg, fg, surface, muted, border) = if dark {
15972        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15973    } else {
15974        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15975    };
15976
15977    let mut lang_rows = String::new();
15978    for (name, files, code) in languages {
15979        write!(
15980            lang_rows,
15981            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15982            escape_html(name),
15983            format_number(*files),
15984            format_number(*code),
15985        )
15986        .ok();
15987    }
15988
15989    let lang_table = if lang_rows.is_empty() {
15990        String::new()
15991    } else {
15992        format!(
15993            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15994        )
15995    };
15996
15997    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15998    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15999    let project_esc = escape_html(&entry.project_label);
16000    let code_lines = format_number(s.code_lines);
16001    let comment_lines = format_number(s.comment_lines);
16002    let files = format_number(s.files_analyzed);
16003    let code_raw = s.code_lines;
16004    let comment_raw = s.comment_lines;
16005    let blank_raw = s.blank_lines;
16006
16007    format!(
16008        r#"<!doctype html>
16009<html lang="en">
16010<head>
16011  <meta charset="utf-8">
16012  <meta name="viewport" content="width=device-width,initial-scale=1">
16013  <title>OxideSLOC &mdash; {project_esc}</title>
16014  <script src="/static/chart.js"></script>
16015  <style nonce="{csp_nonce}">
16016    *{{box-sizing:border-box;margin:0;padding:0}}
16017    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16018    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16019    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16020    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16021    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16022    .card .v{{font-size:18px;font-weight:700}}
16023    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16024    .row{{display:flex;gap:12px;align-items:flex-start}}
16025    .pie{{width:120px;height:120px;flex-shrink:0}}
16026    .lt{{border-collapse:collapse;width:100%;flex:1}}
16027    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16028    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16029    .n{{text-align:right}}
16030    .footer{{margin-top:10px;color:{muted};font-size:10px}}
16031  </style>
16032</head>
16033<body>
16034  <h2>{project_esc}</h2>
16035  <div class="sub">{timestamp} &middot; run {run_short}</div>
16036  <div class="cards">
16037    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16038    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16039    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16040    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16041  </div>
16042  <div class="row">
16043    <canvas class="pie" id="c"></canvas>
16044    {lang_table}
16045  </div>
16046  <div class="footer">oxide-sloc</div>
16047  <script nonce="{csp_nonce}">
16048    new Chart(document.getElementById('c'),{{
16049      type:'doughnut',
16050      data:{{
16051        labels:['Code','Comments','Blank'],
16052        datasets:[{{
16053          data:[{code_raw},{comment_raw},{blank_raw}],
16054          backgroundColor:['#4a78ee','#b35428','#aaa'],
16055          borderWidth:0
16056        }}]
16057      }},
16058      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16059    }});
16060  </script>
16061</body>
16062</html>"#
16063    )
16064}
16065
16066/// Returns a process-wide mutex unique to `dir`, so that two requests writing
16067/// artifacts into the *same* output directory (e.g. re-ingesting an identical
16068/// `run_id`) serialize instead of corrupting each other's files. Directories that
16069/// differ never contend, so legitimate parallel analyses keep their throughput.
16070fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16071    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16072        OnceLock::new();
16073    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16074    let mut guard = map
16075        .lock()
16076        .unwrap_or_else(std::sync::PoisonError::into_inner);
16077    guard
16078        .entry(dir.to_path_buf())
16079        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16080        .clone()
16081}
16082
16083#[allow(clippy::too_many_lines)]
16084fn persist_run_artifacts(
16085    run: &sloc_core::AnalysisRun,
16086    report_html: &str,
16087    run_dir: &Path,
16088    report_title: &str,
16089    file_stem: &str,
16090    result_context: RunResultContext,
16091) -> Result<(RunArtifacts, PendingPdf)> {
16092    // Serialize concurrent writers targeting this same output directory so their
16093    // file writes cannot interleave and corrupt one another.
16094    let dir_lock = output_dir_lock(run_dir);
16095    let _dir_guard = dir_lock
16096        .lock()
16097        .unwrap_or_else(std::sync::PoisonError::into_inner);
16098
16099    // Root dir + organised subdirectories.
16100    let html_dir = run_dir.join("html");
16101    let pdf_dir = run_dir.join("pdf");
16102    let excel_dir = run_dir.join("excel");
16103    let json_dir = run_dir.join("json");
16104    let submodules_dir = run_dir.join("submodules");
16105    for dir in &[
16106        run_dir,
16107        &html_dir,
16108        &pdf_dir,
16109        &excel_dir,
16110        &json_dir,
16111        &submodules_dir,
16112    ] {
16113        fs::create_dir_all(dir)
16114            .with_context(|| format!("failed to create directory {}", dir.display()))?;
16115    }
16116
16117    // HTML report in html/.
16118    let html_path = {
16119        let path = html_dir.join(format!("report_{file_stem}.html"));
16120        fs::write(&path, report_html)
16121            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16122        Some(path)
16123    };
16124
16125    // JSON result in json/.
16126    let json_path = {
16127        let path = json_dir.join(format!("result_{file_stem}.json"));
16128        let json = serde_json::to_string_pretty(run)
16129            .context("failed to serialize analysis run to JSON")?;
16130        fs::write(&path, json)
16131            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16132        Some(path)
16133    };
16134
16135    // PDF in pdf/.
16136    let (pdf_path, pending_pdf) = {
16137        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16138        match write_pdf_from_run(run, &pdf_dest) {
16139            Ok(()) => {
16140                eprintln!(
16141                    "[oxide-sloc][pdf] native PDF written to {}",
16142                    pdf_dest.display()
16143                );
16144                (Some(pdf_dest), None)
16145            }
16146            Err(native_err) => {
16147                eprintln!(
16148                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16149                );
16150                let source_html_path = html_path
16151                    .as_ref()
16152                    .expect("html_path always Some here")
16153                    .clone();
16154                let pending = Some((source_html_path, pdf_dest.clone(), false));
16155                (Some(pdf_dest), pending)
16156            }
16157        }
16158    };
16159
16160    // CSV and XLSX in excel/.
16161    let csv_path = {
16162        let path = excel_dir.join(format!("report_{file_stem}.csv"));
16163        match sloc_report::write_csv(run, &path) {
16164            Err(e) => {
16165                eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16166                None
16167            }
16168            _ => Some(path),
16169        }
16170    };
16171
16172    let xlsx_path = {
16173        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16174        match sloc_report::write_xlsx(run, &path) {
16175            Err(e) => {
16176                eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16177                None
16178            }
16179            _ => Some(path),
16180        }
16181    };
16182
16183    // Scan config in json/.
16184    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16185
16186    // Eagerly generate sub-reports before index.html so relative links work.
16187    if run.effective_configuration.discovery.submodule_breakdown {
16188        let run_id = &run.tool.run_id;
16189        for s in &run.submodule_summaries {
16190            build_submodule_row(s, run, run_id, run_dir);
16191        }
16192    }
16193
16194    // index.html at root — offline static export of the result-page dashboard.
16195    generate_offline_index(
16196        run,
16197        run_dir,
16198        file_stem,
16199        html_path.as_deref(),
16200        pdf_path.as_deref(),
16201        json_path.as_deref(),
16202        scan_config_path.as_deref(),
16203        &result_context,
16204    );
16205
16206    Ok((
16207        RunArtifacts {
16208            output_dir: run_dir.to_path_buf(),
16209            html_path,
16210            pdf_path,
16211            json_path,
16212            csv_path,
16213            xlsx_path,
16214            scan_config_path,
16215            report_title: report_title.to_string(),
16216            result_context,
16217        },
16218        pending_pdf,
16219    ))
16220}
16221
16222/// Render a static offline result-page dashboard and write it as `index.html` at
16223/// the root of the run output directory so business users can open it from disk.
16224#[allow(clippy::too_many_arguments)]
16225#[allow(clippy::too_many_lines)]
16226#[allow(clippy::similar_names)]
16227fn generate_offline_index(
16228    run: &sloc_core::AnalysisRun,
16229    run_dir: &Path,
16230    file_stem: &str,
16231    html_path: Option<&Path>,
16232    pdf_path: Option<&Path>,
16233    json_path: Option<&Path>,
16234    scan_config_path: Option<&Path>,
16235    result_context: &RunResultContext,
16236) {
16237    let prev_entry = &result_context.prev_entry;
16238    let prev_scan_count = result_context.prev_scan_count;
16239    let project_path = &result_context.project_path;
16240
16241    let scan_delta = prev_entry.as_ref().and_then(|prev| {
16242        prev.json_path
16243            .as_ref()
16244            .and_then(|p| read_json(p).ok())
16245            .map(|prev_run| compute_delta(&prev_run, run))
16246    });
16247
16248    let files_analyzed = run.per_file_records.len() as u64;
16249    let files_skipped = run.skipped_file_records.len() as u64;
16250    let totals = sum_lang_totals(run);
16251
16252    let DeltaFields {
16253        prev_fa_str,
16254        prev_fs_str,
16255        prev_pl_str,
16256        prev_cl_str,
16257        prev_cml_str,
16258        prev_bl_str,
16259        delta_fa_str,
16260        delta_fa_class,
16261        delta_fs_str,
16262        delta_fs_class,
16263        delta_pl_str,
16264        delta_pl_class,
16265        delta_cl_str,
16266        delta_cl_class,
16267        delta_cml_str,
16268        delta_cml_class,
16269        delta_bl_str,
16270        delta_bl_class,
16271        delta_lines_added,
16272        delta_lines_removed,
16273        delta_lines_net_str,
16274        delta_lines_net_class,
16275    } = compute_delta_fields(
16276        prev_entry.as_ref(),
16277        &totals,
16278        files_analyzed,
16279        files_skipped,
16280        scan_delta.as_ref(),
16281    );
16282
16283    let git_commit_url = git_commit_url_for(run);
16284    let git_branch_url = git_branch_url_for(run);
16285    let scan_performed_by = scan_performed_by(run);
16286
16287    // Convert absolute path to relative from run_dir (for file:// navigation).
16288    let make_rel = |p: Option<&Path>| -> Option<String> {
16289        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16290            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16291    };
16292
16293    let run_id = &run.tool.run_id;
16294
16295    // Submodule rows with relative paths into submodules/.
16296    let submodule_rows: Vec<SubmoduleRow> = run
16297        .submodule_summaries
16298        .iter()
16299        .map(|s| {
16300            let safe = sanitize_project_label(&s.name);
16301            let key = format!("sub_{safe}");
16302            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16303            SubmoduleRow {
16304                name: s.name.clone(),
16305                relative_path: s.relative_path.clone(),
16306                files_analyzed: s.files_analyzed,
16307                code_lines: s.code_lines,
16308                comment_lines: s.comment_lines,
16309                blank_lines: s.blank_lines,
16310                total_physical_lines: s.total_physical_lines,
16311                html_url: if sub_path.exists() {
16312                    Some(format!("submodules/{key}.html"))
16313                } else {
16314                    None
16315                },
16316            }
16317        })
16318        .collect();
16319
16320    let lang_chart_json = build_lang_chart_json(run);
16321
16322    let scan_config_rel =
16323        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16324
16325    let template = ResultTemplate {
16326        version: env!("CARGO_PKG_VERSION"),
16327        report_title: run.effective_configuration.reporting.report_title.clone(),
16328        project_path: project_path.clone(),
16329        output_dir: display_path(run_dir),
16330        run_id: run_id.clone(),
16331        run_id_short: run_id
16332            .split('-')
16333            .next_back()
16334            .unwrap_or(run_id)
16335            .chars()
16336            .take(7)
16337            .collect(),
16338        files_analyzed,
16339        files_skipped,
16340        physical_lines: totals.physical_lines,
16341        code_lines: totals.code_lines,
16342        comment_lines: totals.comment_lines,
16343        blank_lines: totals.blank_lines,
16344        mixed_lines: totals.mixed_lines,
16345        functions: totals.functions,
16346        classes: totals.classes,
16347        variables: totals.variables,
16348        imports: totals.imports,
16349        html_url: make_rel(html_path),
16350        pdf_url: make_rel(pdf_path),
16351        json_url: make_rel(json_path),
16352        html_download_url: make_rel(html_path),
16353        pdf_download_url: make_rel(pdf_path),
16354        json_download_url: make_rel(json_path),
16355        html_path: html_path.map(display_path),
16356        json_path: json_path.map(display_path),
16357        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16358        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16359        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16360        prev_fa_str,
16361        prev_fs_str,
16362        prev_pl_str,
16363        prev_cl_str,
16364        prev_cml_str,
16365        prev_bl_str,
16366        delta_fa_str,
16367        delta_fa_class,
16368        delta_fs_str,
16369        delta_fs_class,
16370        delta_pl_str,
16371        delta_pl_class,
16372        delta_cl_str,
16373        delta_cl_class,
16374        delta_cml_str,
16375        delta_cml_class,
16376        delta_bl_str,
16377        delta_bl_class,
16378        delta_lines_added,
16379        delta_lines_removed,
16380        delta_lines_net_str,
16381        delta_lines_net_class,
16382        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16383        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16384        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16385        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16386        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16387        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16388        git_branch: run.git_branch.clone(),
16389        git_branch_url,
16390        git_commit: run.git_commit_short.clone(),
16391        git_commit_long: run.git_commit_long.clone(),
16392        git_author: run.git_commit_author.clone(),
16393        git_commit_url,
16394        scan_performed_by,
16395        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16396        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16397        os_display: format!(
16398            "{} / {}",
16399            run.environment.operating_system, run.environment.architecture
16400        ),
16401        test_count: run.summary_totals.test_count,
16402        test_assertion_count: run.summary_totals.test_assertion_count,
16403        current_scan_number: prev_scan_count + 1,
16404        prev_scan_count,
16405        submodule_rows,
16406        pdf_generating: false,
16407        scan_config_url: scan_config_rel,
16408        lang_chart_json,
16409        scatter_chart_json: build_scatter_chart_json(run),
16410        semantic_chart_json: build_semantic_chart_json(run),
16411        submodule_chart_json: build_submodule_chart_json(run),
16412        has_submodule_data: !run.submodule_summaries.is_empty(),
16413        has_semantic_data: run
16414            .totals_by_language
16415            .iter()
16416            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16417        csp_nonce: String::new(),
16418        confluence_configured: false,
16419        server_mode: false,
16420        report_header_footer: run
16421            .effective_configuration
16422            .reporting
16423            .report_header_footer
16424            .clone(),
16425        is_offline: true,
16426        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16427        lsloc: run.summary_totals.lsloc,
16428        uloc: run.uloc,
16429        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16430        duplicate_group_count: run.duplicate_groups.len(),
16431        has_cocomo: run.cocomo.is_some(),
16432        cocomo_effort_str: run
16433            .cocomo
16434            .as_ref()
16435            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16436        cocomo_duration_str: run
16437            .cocomo
16438            .as_ref()
16439            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16440        cocomo_staff_str: run
16441            .cocomo
16442            .as_ref()
16443            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16444        cocomo_ksloc_str: run
16445            .cocomo
16446            .as_ref()
16447            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16448        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16449            || "Organic".to_string(),
16450            |c| cocomo_mode_label(c.mode).to_string(),
16451        ),
16452        cocomo_mode_tooltip: run
16453            .cocomo
16454            .as_ref()
16455            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16456        complexity_alert: 0,
16457        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16458        cov_line_pct: cov_pct_str(
16459            run.summary_totals.coverage_lines_hit,
16460            run.summary_totals.coverage_lines_found,
16461        ),
16462        cov_fn_pct: cov_pct_str(
16463            run.summary_totals.coverage_functions_hit,
16464            run.summary_totals.coverage_functions_found,
16465        ),
16466        cov_branch_pct: cov_pct_str(
16467            run.summary_totals.coverage_branches_hit,
16468            run.summary_totals.coverage_branches_found,
16469        ),
16470        cov_lines_summary: cov_lines_summary_str(
16471            run.summary_totals.coverage_lines_hit,
16472            run.summary_totals.coverage_lines_found,
16473        ),
16474    };
16475
16476    if let Ok(html) = template.render() {
16477        // Inline the brand + watermark logos as data URIs: a file:// page has no
16478        // server to resolve the /images/logo/* routes, so without this the top-left
16479        // logo and the repeated "Oxide" background watermark render as broken images.
16480        let html = inline_offline_logos(&html);
16481        let index_path = run_dir.join("index.html");
16482        if let Err(e) = fs::write(&index_path, html) {
16483            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16484        }
16485    }
16486}
16487
16488/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
16489/// offline `index.html` displays the brand logo and background watermark when
16490/// opened directly from disk (file://), where the `/images/...` routes do not exist.
16491fn inline_offline_logos(html: &str) -> String {
16492    use base64::Engine;
16493    let text_uri = format!(
16494        "data:image/png;base64,{}",
16495        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16496    );
16497    let small_uri = format!(
16498        "data:image/png;base64,{}",
16499        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16500    );
16501    html.replace("/images/logo/logo-text.png", &text_uri)
16502        .replace("/images/logo/small-logo.png", &small_uri)
16503}
16504
16505/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
16506/// then root (old flat layout), for backwards compatibility.
16507fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16508    // New layout: json/scan-config_*.json
16509    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16510        return Some(found);
16511    }
16512    // Old flat layout: scan-config.json or scan-config_*.json at root
16513    find_scan_config_in_dir_flat(dir)
16514}
16515
16516fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16517    let exact = dir.join("scan-config.json");
16518    if exact.exists() {
16519        return Some(exact);
16520    }
16521    fs::read_dir(dir).ok().and_then(|entries| {
16522        entries
16523            .filter_map(std::result::Result::ok)
16524            .find(|e| {
16525                let name = e.file_name();
16526                let name = name.to_string_lossy();
16527                name.starts_with("scan-config") && name.ends_with(".json")
16528            })
16529            .map(|e| e.path())
16530    })
16531}
16532
16533// ── Config export / import ────────────────────────────────────────────────────
16534
16535/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
16536/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
16537#[derive(Deserialize)]
16538struct ExportPdfRequest {
16539    html: String,
16540    #[serde(default)]
16541    filename: Option<String>,
16542}
16543
16544async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16545    let html_content = body.html;
16546    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16547    if html_content.is_empty() {
16548        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16549    }
16550    // Write HTML to a temp file, run headless Chrome PDF export, read result.
16551    let tmp_dir = std::env::temp_dir();
16552    let html_path = tmp_dir.join(format!(
16553        "sloc-export-{}.html",
16554        uuid::Uuid::new_v4().simple()
16555    ));
16556    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16557    if let Err(e) = std::fs::write(&html_path, &html_content) {
16558        return (
16559            StatusCode::INTERNAL_SERVER_ERROR,
16560            format!("Failed to write temp HTML: {e}"),
16561        )
16562            .into_response();
16563    }
16564    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16565    let _ = std::fs::remove_file(&html_path);
16566    if let Err(e) = pdf_result {
16567        let _ = std::fs::remove_file(&pdf_path);
16568        return (
16569            StatusCode::INTERNAL_SERVER_ERROR,
16570            format!("PDF generation failed: {e}"),
16571        )
16572            .into_response();
16573    }
16574    let pdf_bytes = match std::fs::read(&pdf_path) {
16575        Ok(b) => b,
16576        Err(e) => {
16577            let _ = std::fs::remove_file(&pdf_path);
16578            return (
16579                StatusCode::INTERNAL_SERVER_ERROR,
16580                format!("Failed to read PDF: {e}"),
16581            )
16582                .into_response();
16583        }
16584    };
16585    let _ = std::fs::remove_file(&pdf_path);
16586    let safe_name: String = filename
16587        .chars()
16588        .map(|c| {
16589            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16590                c
16591            } else {
16592                '_'
16593            }
16594        })
16595        .collect();
16596    let disposition = format!("attachment; filename=\"{safe_name}\"");
16597    (
16598        [
16599            (header::CONTENT_TYPE, "application/pdf".to_string()),
16600            (header::CONTENT_DISPOSITION, disposition),
16601        ],
16602        pdf_bytes,
16603    )
16604        .into_response()
16605}
16606
16607async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16608    let toml_str = match toml::to_string_pretty(&state.base_config) {
16609        Ok(s) => s,
16610        Err(e) => {
16611            return (
16612                StatusCode::INTERNAL_SERVER_ERROR,
16613                format!("serialization error: {e}"),
16614            )
16615                .into_response();
16616        }
16617    };
16618    (
16619        [
16620            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16621            (
16622                header::CONTENT_DISPOSITION,
16623                "attachment; filename=\".oxide-sloc.toml\"",
16624            ),
16625        ],
16626        toml_str,
16627    )
16628        .into_response()
16629}
16630
16631#[derive(Serialize)]
16632struct OkResponse {
16633    ok: bool,
16634}
16635
16636#[derive(Serialize)]
16637struct SaveProfileResponse {
16638    ok: bool,
16639    id: String,
16640}
16641
16642#[derive(Serialize)]
16643struct ProfileListResponse {
16644    profiles: Vec<ScanProfile>,
16645}
16646
16647#[derive(Serialize)]
16648struct ImportConfigResponse {
16649    ok: bool,
16650    config: sloc_config::AppConfig,
16651}
16652
16653#[derive(Deserialize)]
16654struct ImportConfigBody {
16655    toml: String,
16656}
16657
16658async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16659    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16660        Ok(config) => {
16661            if let Err(e) = config.validate() {
16662                return error::unprocessable_entity(&e.to_string());
16663            }
16664            Json(ImportConfigResponse { ok: true, config }).into_response()
16665        }
16666        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16667    }
16668}
16669
16670// ── Scan profiles API ─────────────────────────────────────────────────────────
16671
16672async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16673    let store = state.scan_profiles.lock().await;
16674    Json(ProfileListResponse {
16675        profiles: store.profiles.clone(),
16676    })
16677}
16678
16679#[derive(Deserialize)]
16680struct SaveScanProfileBody {
16681    name: String,
16682    params: serde_json::Value,
16683}
16684
16685async fn api_save_scan_profile(
16686    State(state): State<AppState>,
16687    Json(body): Json<SaveScanProfileBody>,
16688) -> impl IntoResponse {
16689    if body.name.trim().is_empty() {
16690        return error::bad_request("name must not be empty");
16691    }
16692
16693    let id = uuid::Uuid::new_v4().to_string();
16694    let profile = ScanProfile {
16695        id: id.clone(),
16696        name: body.name.trim().to_string(),
16697        created_at: chrono::Utc::now().to_rfc3339(),
16698        params: body.params,
16699    };
16700
16701    let mut store = state.scan_profiles.lock().await;
16702    store.profiles.push(profile);
16703    if let Err(e) = store.save(&state.scan_profiles_path) {
16704        tracing::warn!("failed to persist scan profiles: {e}");
16705    }
16706    drop(store);
16707
16708    (
16709        StatusCode::CREATED,
16710        Json(SaveProfileResponse { ok: true, id }),
16711    )
16712        .into_response()
16713}
16714
16715async fn api_delete_scan_profile(
16716    State(state): State<AppState>,
16717    AxumPath(id): AxumPath<String>,
16718) -> impl IntoResponse {
16719    let mut store = state.scan_profiles.lock().await;
16720    let before = store.profiles.len();
16721    store.profiles.retain(|p| p.id != id);
16722    if store.profiles.len() == before {
16723        drop(store);
16724        return error::not_found("profile not found");
16725    }
16726    if let Err(e) = store.save(&state.scan_profiles_path) {
16727        tracing::warn!("failed to persist scan profiles: {e}");
16728    }
16729    drop(store);
16730    Json(OkResponse { ok: true }).into_response()
16731}
16732
16733fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16734    let value = raw.unwrap_or("out/web").trim();
16735    let path = if value.is_empty() {
16736        PathBuf::from("out/web")
16737    } else {
16738        PathBuf::from(value)
16739    };
16740
16741    if path.is_absolute() {
16742        path
16743    } else {
16744        workspace_root().join(path)
16745    }
16746}
16747
16748/// Derive the directory that holds remote-repo clones from the output root.
16749fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16750    std::env::var("SLOC_GIT_CLONES_DIR")
16751        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16752}
16753
16754/// Build a deterministic filesystem path for a cloned remote repository.
16755/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16756pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16757    let safe: String = repo_url
16758        .chars()
16759        .map(|c| {
16760            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16761                c
16762            } else {
16763                '_'
16764            }
16765        })
16766        .take(80)
16767        .collect();
16768    clones_dir.join(safe)
16769}
16770
16771/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16772/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16773pub(crate) fn scan_path_to_artifacts(
16774    scan_path: &Path,
16775    base_config: &AppConfig,
16776    label: &str,
16777) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16778    let mut config = base_config.clone();
16779    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16780    label.clone_into(&mut config.reporting.report_title);
16781    let run = analyze(&config, "git", None, None)?;
16782    let html = render_html(&run)?;
16783    let run_id = run.tool.run_id.clone();
16784    let project_label = sanitize_project_label(label);
16785    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16786    let file_stem = {
16787        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16788        if commit.is_empty() {
16789            project_label
16790        } else {
16791            format!("{project_label}_{commit}")
16792        }
16793    };
16794    let (artifacts, _pending_pdf) = persist_run_artifacts(
16795        &run,
16796        &html,
16797        &output_dir,
16798        label,
16799        &file_stem,
16800        RunResultContext::default(),
16801    )?;
16802    Ok((run_id, artifacts, run))
16803}
16804
16805/// Re-spawn background poll tasks for any polling schedules saved to disk.
16806async fn restart_poll_schedules(state: &AppState) {
16807    let store = state.schedules.lock().await;
16808    let poll_schedules: Vec<_> = store
16809        .schedules
16810        .iter()
16811        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16812        .cloned()
16813        .collect();
16814    drop(store);
16815    for schedule in poll_schedules {
16816        let interval = schedule.interval_secs.unwrap_or(300);
16817        let st = state.clone();
16818        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16819    }
16820}
16821
16822/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16823/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16824/// header (no HMAC over the body), so the token is exposed in cleartext unless
16825/// the transport is encrypted. This is only an advisory — TLS may be terminated
16826/// by an upstream reverse proxy, in which case the warning can be ignored.
16827async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16828    if state.tls_enabled {
16829        return;
16830    }
16831    let store = state.schedules.lock().await;
16832    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16833        s.kind == sloc_git::ScanScheduleKind::Webhook
16834            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16835    });
16836    drop(store);
16837    if has_gitlab_webhook {
16838        tracing::warn!(
16839            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16840             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16841             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16842             proxy so the token is not exposed in cleartext."
16843        );
16844    }
16845}
16846
16847fn split_patterns(raw: Option<&str>) -> Vec<String> {
16848    raw.unwrap_or("")
16849        .lines()
16850        .flat_map(|line| line.split(','))
16851        .map(str::trim)
16852        .filter(|part| !part.is_empty())
16853        .map(ToOwned::to_owned)
16854        .collect()
16855}
16856
16857#[must_use]
16858pub fn build_sub_run(
16859    parent: &AnalysisRun,
16860    sub: &sloc_core::SubmoduleSummary,
16861    parent_path: &str,
16862) -> AnalysisRun {
16863    let sub_files: Vec<_> = parent
16864        .per_file_records
16865        .iter()
16866        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16867        .cloned()
16868        .collect();
16869    let mut config = parent.effective_configuration.clone();
16870    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16871
16872    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16873    let mut functions = 0u64;
16874    let mut classes = 0u64;
16875    let mut variables = 0u64;
16876    let mut imports = 0u64;
16877    let mut test_count = 0u64;
16878    let mut test_assertion_count = 0u64;
16879    let mut test_suite_count = 0u64;
16880    let mut mixed_lines_separate = 0u64;
16881    let mut coverage_lines_found = 0u64;
16882    let mut coverage_lines_hit = 0u64;
16883    let mut coverage_functions_found = 0u64;
16884    let mut coverage_functions_hit = 0u64;
16885    let mut coverage_branches_found = 0u64;
16886    let mut coverage_branches_hit = 0u64;
16887    for r in &sub_files {
16888        functions += r.raw_line_categories.functions;
16889        classes += r.raw_line_categories.classes;
16890        variables += r.raw_line_categories.variables;
16891        imports += r.raw_line_categories.imports;
16892        test_count += r.raw_line_categories.test_count;
16893        test_assertion_count += r.raw_line_categories.test_assertion_count;
16894        test_suite_count += r.raw_line_categories.test_suite_count;
16895        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16896        if let Some(cov) = &r.coverage {
16897            coverage_lines_found += u64::from(cov.lines_found);
16898            coverage_lines_hit += u64::from(cov.lines_hit);
16899            coverage_functions_found += u64::from(cov.functions_found);
16900            coverage_functions_hit += u64::from(cov.functions_hit);
16901            coverage_branches_found += u64::from(cov.branches_found);
16902            coverage_branches_hit += u64::from(cov.branches_hit);
16903        }
16904    }
16905
16906    AnalysisRun {
16907        tool: parent.tool.clone(),
16908        environment: parent.environment.clone(),
16909        effective_configuration: config,
16910        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16911        summary_totals: SummaryTotals {
16912            files_considered: sub.files_analyzed,
16913            files_analyzed: sub.files_analyzed,
16914            files_skipped: 0,
16915            total_physical_lines: sub.total_physical_lines,
16916            code_lines: sub.code_lines,
16917            comment_lines: sub.comment_lines,
16918            blank_lines: sub.blank_lines,
16919            mixed_lines_separate,
16920            functions,
16921            classes,
16922            variables,
16923            imports,
16924            test_count,
16925            test_assertion_count,
16926            test_suite_count,
16927            coverage_lines_found,
16928            coverage_lines_hit,
16929            coverage_functions_found,
16930            coverage_functions_hit,
16931            coverage_branches_found,
16932            coverage_branches_hit,
16933            cyclomatic_complexity: 0,
16934            lsloc: None,
16935            ..Default::default()
16936        },
16937        totals_by_language: sub.language_summaries.clone(),
16938        per_file_records: sub_files,
16939        skipped_file_records: vec![],
16940        warnings: vec![],
16941        submodule_summaries: vec![],
16942        git_commit_short: sub.git_commit_short.clone(),
16943        git_commit_long: sub.git_commit_long.clone(),
16944        git_branch: sub.git_branch.clone(),
16945        git_commit_author: sub.git_commit_author.clone(),
16946        git_commit_date: sub.git_commit_date.clone(),
16947        git_tags: None,
16948        git_nearest_tag: None,
16949        git_remote_url: sub.git_remote_url.clone(),
16950        style_summary: None,
16951        cocomo: None,
16952        uloc: 0,
16953        dryness_pct: None,
16954        duplicate_groups: vec![],
16955        duplicates_excluded: 0,
16956    }
16957}
16958
16959#[must_use]
16960pub fn sanitize_project_label(raw: &str) -> String {
16961    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16962    // where `Path` treats '\' as a literal character, not a separator.
16963    let candidate = raw
16964        .split(['/', '\\'])
16965        .rfind(|s| !s.is_empty())
16966        .unwrap_or("project");
16967
16968    let mut value = String::with_capacity(candidate.len());
16969    for ch in candidate.chars() {
16970        if ch.is_ascii_alphanumeric() {
16971            value.push(ch.to_ascii_lowercase());
16972        } else {
16973            value.push('-');
16974        }
16975    }
16976
16977    let compact = value.trim_matches('-').to_string();
16978    if compact.is_empty() {
16979        "project".to_string()
16980    } else {
16981        compact
16982    }
16983}
16984
16985/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16986/// comparisons with non-canonicalized stored paths work correctly.
16987fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16988    let s = path.to_string_lossy();
16989    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16990        return PathBuf::from(format!(r"\\{rest}"));
16991    }
16992    if let Some(rest) = s.strip_prefix(r"\\?\") {
16993        return PathBuf::from(rest);
16994    }
16995    path
16996}
16997
16998/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16999/// commit page URL for the most common hosting platforms.
17000fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
17001    let base = if let Some(rest) = remote.strip_prefix("git@") {
17002        let (host, path) = rest.split_once(':')?;
17003        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17004    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17005        remote
17006            .trim_end_matches('/')
17007            .trim_end_matches(".git")
17008            .to_owned()
17009    } else {
17010        return None;
17011    };
17012    let base = base.trim_end_matches('/');
17013    // GitLab uses /-/commit/; everything else uses /commit/
17014    if base.contains("gitlab.com") || base.contains("gitlab.") {
17015        Some(format!("{base}/-/commit/{sha}"))
17016    } else if base.contains("bitbucket.org") {
17017        Some(format!("{base}/commits/{sha}"))
17018    } else {
17019        Some(format!("{base}/commit/{sha}"))
17020    }
17021}
17022
17023/// Convert a git remote URL (https or git@) + branch name into a browser-openable
17024/// branch page URL for the most common hosting platforms.
17025fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17026    let base = if let Some(rest) = remote.strip_prefix("git@") {
17027        let (host, path) = rest.split_once(':')?;
17028        format!("https://{}/{}", host, path.trim_end_matches(".git"))
17029    } else if remote.starts_with("https://") || remote.starts_with("http://") {
17030        remote
17031            .trim_end_matches('/')
17032            .trim_end_matches(".git")
17033            .to_owned()
17034    } else {
17035        return None;
17036    };
17037    let base = base.trim_end_matches('/');
17038    if base.contains("gitlab.com") || base.contains("gitlab.") {
17039        Some(format!("{base}/-/tree/{branch}"))
17040    } else {
17041        Some(format!("{base}/tree/{branch}"))
17042    }
17043}
17044
17045fn display_path(path: &Path) -> String {
17046    let s = path.to_string_lossy();
17047    // Strip Windows extended-length prefix for display only; the underlying
17048    // PathBuf remains unchanged so file operations are unaffected.
17049    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
17050    // \\?\C:\path           →  C:\path          (local drive)
17051    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17052        return format!(r"\\{rest}");
17053    }
17054    if let Some(rest) = s.strip_prefix(r"\\?\") {
17055        return rest.to_owned();
17056    }
17057    s.into_owned()
17058}
17059
17060fn sanitize_path_str(s: &str) -> String {
17061    // Forward-slash variants of the Windows extended-length prefix that appear
17062    // when paths stored as plain strings have been processed through some path
17063    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
17064    if let Some(rest) = s.strip_prefix("//?/UNC/") {
17065        return format!("//{rest}");
17066    }
17067    if let Some(rest) = s.strip_prefix("//?/") {
17068        return rest.to_owned();
17069    }
17070    display_path(Path::new(s))
17071}
17072
17073fn workspace_root() -> PathBuf {
17074    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
17075    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17076        let p = PathBuf::from(root);
17077        if p.is_dir() {
17078            return p;
17079        }
17080    }
17081
17082    // Current working directory — works for `cargo run` from the project root
17083    // and for scripts/run.sh which cds there first.
17084    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17085}
17086
17087/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
17088fn make_git_label(repo: &str, ref_name: &str) -> String {
17089    if repo.is_empty() || ref_name.is_empty() {
17090        return String::new();
17091    }
17092    let base = repo
17093        .trim_end_matches('/')
17094        .trim_end_matches(".git")
17095        .rsplit('/')
17096        .next()
17097        .unwrap_or("repo");
17098    let ref_safe: String = ref_name
17099        .chars()
17100        .map(|c| {
17101            if c.is_alphanumeric() || c == '-' || c == '.' {
17102                c
17103            } else {
17104                '_'
17105            }
17106        })
17107        .collect();
17108    format!("{base}_at_{ref_safe}_sloc")
17109}
17110
17111/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
17112fn desktop_dir() -> PathBuf {
17113    if let Ok(profile) = std::env::var("USERPROFILE") {
17114        let p = PathBuf::from(profile).join("Desktop");
17115        if p.exists() {
17116            return p;
17117        }
17118    }
17119    if let Ok(home) = std::env::var("HOME") {
17120        let p = PathBuf::from(home).join("Desktop");
17121        if p.exists() {
17122            return p;
17123        }
17124    }
17125    workspace_root().join("out").join("web")
17126}
17127
17128fn resolve_input_path(raw: &str) -> PathBuf {
17129    let trimmed = raw.trim();
17130    if trimmed.is_empty() {
17131        return workspace_root().join("samples").join("basic");
17132    }
17133
17134    let candidate = PathBuf::from(trimmed);
17135    let resolved = if candidate.is_absolute() {
17136        candidate
17137    } else {
17138        let rooted = workspace_root().join(&candidate);
17139        if rooted.exists() {
17140            rooted
17141        } else {
17142            workspace_root().join(candidate)
17143        }
17144    };
17145
17146    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
17147    // strip that prefix so stored paths and the displayed "Project path" are clean.
17148    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17149    PathBuf::from(display_path(&canonical))
17150}
17151
17152fn dir_size_bytes(path: &Path) -> u64 {
17153    let mut total = 0u64;
17154    if let Ok(rd) = fs::read_dir(path) {
17155        for entry in rd.filter_map(Result::ok) {
17156            let p = entry.path();
17157            if p.is_file() {
17158                if let Ok(meta) = p.metadata() {
17159                    total += meta.len();
17160                }
17161            } else if p.is_dir() {
17162                total += dir_size_bytes(&p);
17163            }
17164        }
17165    }
17166    total
17167}
17168
17169#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
17170fn format_dir_size(bytes: u64) -> String {
17171    if bytes >= 1_073_741_824 {
17172        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17173    } else if bytes >= 1_048_576 {
17174        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17175    } else if bytes >= 1_024 {
17176        format!("{:.0} KB", bytes as f64 / 1_024.0)
17177    } else {
17178        format!("{bytes} B")
17179    }
17180}
17181
17182fn render_submodule_chips(
17183    root: &Path,
17184    submodules: &[(String, std::path::PathBuf)],
17185    out: &mut String,
17186) {
17187    use std::fmt::Write as _;
17188    let count = submodules.len();
17189    out.push_str(r#"<div class="submodule-preview-strip">"#);
17190    write!(
17191        out,
17192        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>"#,
17193        if count == 1 { "" } else { "s" }
17194    )
17195    .ok();
17196    out.push_str(r#"<div class="submodule-preview-chips">"#);
17197    for (sub_name, sub_rel_path) in submodules {
17198        let sub_abs = root.join(sub_rel_path);
17199        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17200        let mut sub_stats = PreviewStats::default();
17201        let mut sub_rows: Vec<PreviewRow> = Vec::new();
17202        let mut sub_langs: Vec<&'static str> = Vec::new();
17203        let mut sub_budget = PreviewBudget {
17204            shown: 0,
17205            max_entries: 2000,
17206            max_depth: 9,
17207        };
17208        let mut sub_next_id = 1usize;
17209        let _ = collect_preview_rows(
17210            &sub_abs,
17211            &sub_abs,
17212            0,
17213            None,
17214            &mut sub_next_id,
17215            &mut sub_budget,
17216            &mut sub_stats,
17217            &mut sub_rows,
17218            &mut sub_langs,
17219            &[],
17220            &[],
17221        );
17222        let stats_json = format!(
17223            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17224            sub_stats.directories,
17225            sub_stats.files,
17226            sub_stats.supported,
17227            sub_stats.skipped,
17228            sub_stats.unsupported
17229        );
17230        write!(
17231            out,
17232            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>"#,
17233            escape_html(sub_name),
17234            escape_html(&sub_rel_path.to_string_lossy()),
17235            escape_html(&sub_size),
17236            escape_html(&stats_json),
17237            escape_html(sub_name),
17238            escape_html(&sub_size),
17239        )
17240        .ok();
17241    }
17242    out.push_str(
17243        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
17244    );
17245    out.push_str(r"</div>");
17246}
17247
17248/// Amber caution banner shown when the selected folder spans multiple independent
17249/// git repositories. Each repo is a one-click button that re-selects it as the
17250/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
17251fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17252    use std::fmt::Write as _;
17253    const MAX_LISTED: usize = 5;
17254    let total = layout.nested_repos.len();
17255
17256    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17257    if layout.root_is_repo {
17258        write!(
17259            out,
17260            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>",
17261            if total == 1 { "repository" } else { "repositories" }
17262        )
17263        .ok();
17264    } else {
17265        write!(
17266            out,
17267            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>"
17268        )
17269        .ok();
17270    }
17271
17272    out.push_str(r#"<div class="repo-pick-row">"#);
17273    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17274        let abs = root.join(rel);
17275        let abs_display = display_path(&abs);
17276        let label = rel.to_string_lossy().replace('\\', "/");
17277        write!(
17278            out,
17279            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17280            escape_html(&abs_display),
17281            escape_html(&label)
17282        )
17283        .ok();
17284    }
17285    if total > MAX_LISTED {
17286        write!(
17287            out,
17288            r#"<span class="repo-pick-more">and {} more</span>"#,
17289            total - MAX_LISTED
17290        )
17291        .ok();
17292    }
17293    out.push_str(r"</div>");
17294
17295    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17296    out.push_str(r"</div>");
17297}
17298
17299fn render_language_pills_row(languages: &[&str], out: &mut String) {
17300    use std::fmt::Write as _;
17301    if languages.is_empty() {
17302        out.push_str(
17303            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17304        );
17305        return;
17306    }
17307    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17308    for language in languages {
17309        if let Some(icon) = language_icon_file(language) {
17310            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();
17311        } else if let Some(svg) = language_inline_svg(language) {
17312            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();
17313        } else {
17314            write!(
17315                out,
17316                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17317                escape_html(&language.to_ascii_lowercase()),
17318                escape_html(language)
17319            )
17320            .ok();
17321        }
17322    }
17323}
17324
17325#[allow(clippy::too_many_lines)]
17326fn build_preview_html(
17327    root: &Path,
17328    include_patterns: &[String],
17329    exclude_patterns: &[String],
17330) -> Result<String> {
17331    if !root.exists() {
17332        return Ok(format!(
17333            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17334            escape_html(&display_path(root))
17335        ));
17336    }
17337
17338    let _selected = display_path(root);
17339    let mut stats = PreviewStats::default();
17340    let mut rows = Vec::new();
17341    let mut languages = Vec::new();
17342    let mut budget = PreviewBudget {
17343        shown: 0,
17344        max_entries: 600,
17345        max_depth: 9,
17346    };
17347    let mut next_row_id = 1usize;
17348
17349    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17350        || root.to_string_lossy().into_owned(),
17351        std::string::ToString::to_string,
17352    );
17353    let root_modified = root
17354        .metadata()
17355        .ok()
17356        .and_then(|meta| meta.modified().ok())
17357        .map_or_else(|| "-".to_string(), format_system_time);
17358
17359    rows.push(PreviewRow {
17360        row_id: 0,
17361        parent_row_id: None,
17362        depth: 0,
17363        name: format!("{root_name}/"),
17364        kind: PreviewKind::Dir,
17365        is_dir: true,
17366        language: None,
17367        modified: root_modified,
17368        type_label: "Directory".to_string(),
17369    });
17370    collect_preview_rows(
17371        root,
17372        root,
17373        0,
17374        Some(0),
17375        &mut next_row_id,
17376        &mut budget,
17377        &mut stats,
17378        &mut rows,
17379        &mut languages,
17380        include_patterns,
17381        exclude_patterns,
17382    )?;
17383
17384    let root_size = format_dir_size(dir_size_bytes(root));
17385
17386    let mut out = String::new();
17387    write!(
17388        out,
17389        r#"<div class="explorer-wrap" data-project-size="{}">"#,
17390        escape_html(&root_size)
17391    )
17392    .ok();
17393    out.push_str(r#"<div class="explorer-toolbar compact">"#);
17394    out.push_str(r#"<div class="explorer-title-group">"#);
17395    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17396    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17397    out.push_str(r"</div></div>");
17398
17399    out.push_str(r#"<div class="scope-stats">"#);
17400    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();
17401    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();
17402    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();
17403    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();
17404    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();
17405    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>"#);
17406    out.push_str(r"</div>");
17407
17408    let submodules = sloc_core::detect_submodules(root);
17409    if !submodules.is_empty() {
17410        render_submodule_chips(root, &submodules, &mut out);
17411    }
17412
17413    let repo_layout = sloc_core::detect_repository_layout(root);
17414    if repo_layout.has_multiple_repos() {
17415        render_multi_repo_warning(root, &repo_layout, &mut out);
17416    }
17417
17418    out.push_str(r#"<div class="scope-info-row">"#);
17419    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17420    render_language_pills_row(&languages, &mut out);
17421    out.push_str(r"</div></div>");
17422    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>"#);
17423    out.push_str(r"</div>");
17424
17425    out.push_str(r#"<div class="file-explorer-shell">"#);
17426    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>"#);
17427    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>"#);
17428    out.push_str(r#"<div class="file-explorer-tree">"#);
17429    for row in rows {
17430        let status_label = row.kind.label();
17431        let lang_attr = row.language.unwrap_or("");
17432        let toggle_html = if row.is_dir {
17433            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17434                .to_string()
17435        } else {
17436            r#"<span class="tree-bullet">•</span>"#.to_string()
17437        };
17438        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();
17439    }
17440    if budget.shown >= budget.max_entries {
17441        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>"#);
17442    }
17443    out.push_str(r"</div></div></div>");
17444
17445    Ok(out)
17446}
17447
17448#[derive(Default)]
17449struct PreviewStats {
17450    directories: usize,
17451    files: usize,
17452    supported: usize,
17453    skipped: usize,
17454    unsupported: usize,
17455}
17456
17457struct PreviewRow {
17458    row_id: usize,
17459    parent_row_id: Option<usize>,
17460    depth: usize,
17461    name: String,
17462    kind: PreviewKind,
17463    is_dir: bool,
17464    language: Option<&'static str>,
17465    modified: String,
17466    type_label: String,
17467}
17468
17469#[derive(Copy, Clone)]
17470enum PreviewKind {
17471    Dir,
17472    Supported,
17473    Skipped,
17474    Unsupported,
17475}
17476
17477impl PreviewKind {
17478    const fn filter_key(self) -> &'static str {
17479        match self {
17480            Self::Dir => "dir",
17481            Self::Supported => "supported",
17482            Self::Skipped => "skipped",
17483            Self::Unsupported => "unsupported",
17484        }
17485    }
17486
17487    const fn label(self) -> &'static str {
17488        match self {
17489            Self::Dir => "dir",
17490            Self::Supported => "supported",
17491            Self::Skipped => "skipped by policy",
17492            Self::Unsupported => "unsupported",
17493        }
17494    }
17495
17496    const fn badge_class(self) -> &'static str {
17497        match self {
17498            Self::Dir => "badge badge-dir",
17499            Self::Supported => "badge badge-scan",
17500            Self::Skipped => "badge badge-skip",
17501            Self::Unsupported => "badge badge-unsupported",
17502        }
17503    }
17504
17505    const fn node_class(self) -> &'static str {
17506        match self {
17507            Self::Dir => "tree-node-dir",
17508            Self::Supported => "tree-node-supported",
17509            Self::Skipped => "tree-node-skipped",
17510            Self::Unsupported => "tree-node-unsupported",
17511        }
17512    }
17513}
17514
17515struct PreviewBudget {
17516    shown: usize,
17517    max_entries: usize,
17518    max_depth: usize,
17519}
17520
17521/// Handle a single directory entry inside `collect_preview_rows`.
17522/// Returns `true` when the entry was handled (caller should `continue`).
17523#[allow(clippy::too_many_arguments)]
17524fn handle_preview_dir_entry(
17525    root: &Path,
17526    path: &Path,
17527    name: &str,
17528    modified: String,
17529    depth: usize,
17530    parent_row_id: Option<usize>,
17531    row_id: usize,
17532    next_row_id: &mut usize,
17533    budget: &mut PreviewBudget,
17534    stats: &mut PreviewStats,
17535    rows: &mut Vec<PreviewRow>,
17536    languages: &mut Vec<&'static str>,
17537    include_patterns: &[String],
17538    exclude_patterns: &[String],
17539) -> Result<()> {
17540    let relative = preview_relative_path(root, path);
17541    if should_skip_preview_directory(&relative, exclude_patterns) {
17542        return Ok(());
17543    }
17544    stats.directories += 1;
17545    rows.push(PreviewRow {
17546        row_id,
17547        parent_row_id,
17548        depth: depth + 1,
17549        name: format!("{name}/"),
17550        kind: PreviewKind::Dir,
17551        is_dir: true,
17552        language: None,
17553        modified,
17554        type_label: "Directory".to_string(),
17555    });
17556    budget.shown += 1;
17557    if !matches!(name, ".git" | "node_modules" | "target") {
17558        collect_preview_rows(
17559            root,
17560            path,
17561            depth + 1,
17562            Some(row_id),
17563            next_row_id,
17564            budget,
17565            stats,
17566            rows,
17567            languages,
17568            include_patterns,
17569            exclude_patterns,
17570        )?;
17571    }
17572    Ok(())
17573}
17574
17575/// Handle a single file entry inside `collect_preview_rows`.
17576#[allow(clippy::too_many_arguments)]
17577fn handle_preview_file_entry(
17578    root: &Path,
17579    path: &Path,
17580    name: &str,
17581    modified: String,
17582    depth: usize,
17583    parent_row_id: Option<usize>,
17584    row_id: usize,
17585    budget: &mut PreviewBudget,
17586    stats: &mut PreviewStats,
17587    rows: &mut Vec<PreviewRow>,
17588    languages: &mut Vec<&'static str>,
17589    include_patterns: &[String],
17590    exclude_patterns: &[String],
17591) {
17592    let relative = preview_relative_path(root, path);
17593    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17594        return;
17595    }
17596    stats.files += 1;
17597    let kind = classify_preview_file(name);
17598    match kind {
17599        PreviewKind::Supported => stats.supported += 1,
17600        PreviewKind::Skipped => stats.skipped += 1,
17601        PreviewKind::Unsupported => stats.unsupported += 1,
17602        PreviewKind::Dir => {}
17603    }
17604    let language = detect_language_name(name);
17605    if let Some(lang) = language
17606        && !languages.contains(&lang)
17607    {
17608        languages.push(lang);
17609    }
17610    rows.push(PreviewRow {
17611        row_id,
17612        parent_row_id,
17613        depth: depth + 1,
17614        name: name.to_owned(),
17615        kind,
17616        is_dir: false,
17617        language,
17618        modified,
17619        type_label: preview_type_label(name, language, kind),
17620    });
17621    budget.shown += 1;
17622}
17623
17624#[allow(clippy::too_many_arguments)]
17625#[allow(clippy::too_many_lines)]
17626fn collect_preview_rows(
17627    root: &Path,
17628    dir: &Path,
17629    depth: usize,
17630    parent_row_id: Option<usize>,
17631    next_row_id: &mut usize,
17632    budget: &mut PreviewBudget,
17633    stats: &mut PreviewStats,
17634    rows: &mut Vec<PreviewRow>,
17635    languages: &mut Vec<&'static str>,
17636    include_patterns: &[String],
17637    exclude_patterns: &[String],
17638) -> Result<()> {
17639    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17640        return Ok(());
17641    }
17642
17643    let mut entries = fs::read_dir(dir)
17644        .with_context(|| format!("failed to read directory {}", dir.display()))?
17645        .filter_map(std::result::Result::ok)
17646        .collect::<Vec<_>>();
17647    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17648
17649    for entry in entries {
17650        if budget.shown >= budget.max_entries {
17651            break;
17652        }
17653
17654        let path = entry.path();
17655        let name = entry.file_name().to_string_lossy().into_owned();
17656        let Ok(metadata) = entry.metadata() else {
17657            continue;
17658        };
17659        let row_id = *next_row_id;
17660        *next_row_id += 1;
17661        let modified = metadata
17662            .modified()
17663            .ok()
17664            .map_or_else(|| "-".to_string(), format_system_time);
17665
17666        if metadata.is_dir() {
17667            handle_preview_dir_entry(
17668                root,
17669                &path,
17670                &name,
17671                modified,
17672                depth,
17673                parent_row_id,
17674                row_id,
17675                next_row_id,
17676                budget,
17677                stats,
17678                rows,
17679                languages,
17680                include_patterns,
17681                exclude_patterns,
17682            )?;
17683            continue;
17684        }
17685
17686        if metadata.is_file() {
17687            handle_preview_file_entry(
17688                root,
17689                &path,
17690                &name,
17691                modified,
17692                depth,
17693                parent_row_id,
17694                row_id,
17695                budget,
17696                stats,
17697                rows,
17698                languages,
17699                include_patterns,
17700                exclude_patterns,
17701            );
17702        }
17703    }
17704
17705    Ok(())
17706}
17707
17708fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17709    if let Some(language) = language {
17710        return format!("{language} source");
17711    }
17712    let lower = name.to_ascii_lowercase();
17713    let ext = Path::new(&lower)
17714        .extension()
17715        .and_then(|e| e.to_str())
17716        .unwrap_or("");
17717    match kind {
17718        PreviewKind::Skipped => {
17719            if lower.ends_with(".min.js") {
17720                "Minified asset".to_string()
17721            } else if [
17722                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17723            ]
17724            .contains(&ext)
17725            {
17726                "Binary or archive".to_string()
17727            } else {
17728                "Skipped file".to_string()
17729            }
17730        }
17731        PreviewKind::Unsupported => {
17732            if ext.is_empty() {
17733                "Unsupported file".to_string()
17734            } else {
17735                format!("{} file", ext.to_ascii_uppercase())
17736            }
17737        }
17738        PreviewKind::Supported => "Supported source".to_string(),
17739        PreviewKind::Dir => "Directory".to_string(),
17740    }
17741}
17742
17743fn format_system_time(time: SystemTime) -> String {
17744    #[allow(clippy::cast_possible_wrap)]
17745    let secs = match time.duration_since(UNIX_EPOCH) {
17746        Ok(duration) => duration.as_secs() as i64,
17747        Err(_) => return "-".to_string(),
17748    };
17749    let days = secs.div_euclid(86_400);
17750    let secs_of_day = secs.rem_euclid(86_400);
17751    let (year, month, day) = civil_from_days(days);
17752    let hour = secs_of_day / 3_600;
17753    let minute = (secs_of_day % 3_600) / 60;
17754    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17755}
17756
17757#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17758fn civil_from_days(days: i64) -> (i32, u32, u32) {
17759    let z = days + 719_468;
17760    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17761    let doe = z - era * 146_097;
17762    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17763    let y = yoe + era * 400;
17764    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17765    let mp = (5 * doy + 2) / 153;
17766    let d = doy - (153 * mp + 2) / 5 + 1;
17767    let m = mp + if mp < 10 { 3 } else { -9 };
17768    let year = y + i64::from(m <= 2);
17769    (year as i32, m as u32, d as u32)
17770}
17771
17772// The input is already lowercased via `to_ascii_lowercase()` before calling
17773// `ends_with`, so the comparisons are inherently case-insensitive.
17774#[allow(clippy::case_sensitive_file_extension_comparisons)]
17775fn detect_language_name(name: &str) -> Option<&'static str> {
17776    let lower = name.to_ascii_lowercase();
17777    if lower.ends_with(".c") || lower.ends_with(".h") {
17778        Some("C")
17779    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17780        .iter()
17781        .any(|s| lower.ends_with(s))
17782    {
17783        Some("C++")
17784    } else if lower.ends_with(".cs") {
17785        Some("C#")
17786    } else if lower.ends_with(".py") {
17787        Some("Python")
17788    } else if lower.ends_with(".sh") {
17789        Some("Shell")
17790    } else if [".ps1", ".psm1", ".psd1"]
17791        .iter()
17792        .any(|s| lower.ends_with(s))
17793    {
17794        Some("PowerShell")
17795    } else {
17796        None
17797    }
17798}
17799
17800fn language_icon_file(language: &str) -> Option<&'static str> {
17801    match language {
17802        "C" => Some("c.png"),
17803        "C++" => Some("cpp.png"),
17804        "C#" => Some("c-sharp.png"),
17805        "Python" => Some("python.png"),
17806        "Shell" => Some("shell.png"),
17807        "PowerShell" => Some("powershell.png"),
17808        "JavaScript" => Some("java-script.png"),
17809        "HTML" => Some("html-5.png"),
17810        "Java" => Some("java.png"),
17811        "Visual Basic" => Some("visual-basic.png"),
17812        "Assembly" => Some("asm.png"),
17813        "Go" => Some("go.png"),
17814        "R" => Some("r.png"),
17815        "XML" => Some("xml.png"),
17816        "Groovy" => Some("groovy.png"),
17817        "Dockerfile" => Some("docker.png"),
17818        "Makefile" => Some("makefile.svg"),
17819        "Perl" => Some("perl.svg"),
17820        _ => None,
17821    }
17822}
17823
17824// Inline SVG badges for languages that have no PNG icon in images/icons/.
17825// Using inline SVG keeps the web UI fully self-contained — no extra files
17826// needed on disk, no 404s on air-gapped deployments.
17827// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17828fn language_inline_svg(language: &str) -> Option<&'static str> {
17829    match language {
17830        "Rust" => Some(
17831            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>"##,
17832        ),
17833        "TypeScript" => Some(
17834            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>"##,
17835        ),
17836        _ => None,
17837    }
17838}
17839
17840// The input is already lowercased via `to_ascii_lowercase()` before the
17841// `ends_with` calls, so these comparisons are inherently case-insensitive.
17842#[allow(clippy::case_sensitive_file_extension_comparisons)]
17843fn classify_preview_file(name: &str) -> PreviewKind {
17844    let lower = name.to_ascii_lowercase();
17845
17846    let scannable = [
17847        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17848        ".psm1", ".psd1",
17849    ]
17850    .iter()
17851    .any(|suffix| lower.ends_with(suffix));
17852
17853    if scannable {
17854        PreviewKind::Supported
17855    } else if lower.ends_with(".min.js")
17856        || lower.ends_with(".lock")
17857        || lower.ends_with(".png")
17858        || lower.ends_with(".jpg")
17859        || lower.ends_with(".jpeg")
17860        || lower.ends_with(".gif")
17861        || lower.ends_with(".zip")
17862        || lower.ends_with(".pdf")
17863        || lower.ends_with(".pyc")
17864        || lower.ends_with(".xz")
17865        || lower.ends_with(".tar")
17866        || lower.ends_with(".gz")
17867    {
17868        PreviewKind::Skipped
17869    } else {
17870        PreviewKind::Unsupported
17871    }
17872}
17873
17874fn preview_relative_path(root: &Path, path: &Path) -> String {
17875    path.strip_prefix(root)
17876        .ok()
17877        .unwrap_or(path)
17878        .to_string_lossy()
17879        .replace('\\', "/")
17880        .trim_matches('/')
17881        .to_string()
17882}
17883
17884fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17885    if relative.is_empty() {
17886        return false;
17887    }
17888
17889    exclude_patterns.iter().any(|pattern| {
17890        wildcard_match(pattern, relative)
17891            || wildcard_match(pattern, &format!("{relative}/"))
17892            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17893    })
17894}
17895
17896fn should_include_preview_file(
17897    relative: &str,
17898    include_patterns: &[String],
17899    exclude_patterns: &[String],
17900) -> bool {
17901    if relative.is_empty() {
17902        return true;
17903    }
17904
17905    let included = include_patterns.is_empty()
17906        || include_patterns
17907            .iter()
17908            .any(|pattern| wildcard_match(pattern, relative));
17909    let excluded = exclude_patterns
17910        .iter()
17911        .any(|pattern| wildcard_match(pattern, relative));
17912
17913    included && !excluded
17914}
17915
17916fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17917    let pattern = pattern.trim().replace('\\', "/");
17918    let candidate = candidate.trim().replace('\\', "/");
17919    let p = pattern.as_bytes();
17920    let c = candidate.as_bytes();
17921    let mut pi = 0usize;
17922    let mut ci = 0usize;
17923    let mut star: Option<usize> = None;
17924    let mut star_match = 0usize;
17925
17926    while ci < c.len() {
17927        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17928            pi += 1;
17929            ci += 1;
17930        } else if pi < p.len() && p[pi] == b'*' {
17931            while pi < p.len() && p[pi] == b'*' {
17932                pi += 1;
17933            }
17934            star = Some(pi);
17935            star_match = ci;
17936        } else if let Some(star_pi) = star {
17937            star_match += 1;
17938            ci = star_match;
17939            pi = star_pi;
17940        } else {
17941            return false;
17942        }
17943    }
17944
17945    while pi < p.len() && p[pi] == b'*' {
17946        pi += 1;
17947    }
17948
17949    pi == p.len()
17950}
17951
17952fn escape_html(value: &str) -> String {
17953    value
17954        .replace('&', "&amp;")
17955        .replace('<', "&lt;")
17956        .replace('>', "&gt;")
17957        .replace('"', "&quot;")
17958        .replace('\'', "&#39;")
17959}
17960
17961#[derive(Clone)]
17962struct SubmoduleRow {
17963    name: String,
17964    relative_path: String,
17965    files_analyzed: u64,
17966    code_lines: u64,
17967    comment_lines: u64,
17968    blank_lines: u64,
17969    total_physical_lines: u64,
17970    html_url: Option<String>,
17971}
17972
17973#[derive(Template)]
17974#[template(
17975    source = r##"
17976<!doctype html>
17977<html lang="en">
17978<head>
17979  <meta charset="utf-8">
17980  <title>OxideSLOC | tmp-sloc</title>
17981  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17982  <style nonce="{{ csp_nonce }}">
17983    :root {
17984      --bg: #efe9e2;
17985      --surface: #fcfaf7;
17986      --surface-2: #f7f0e8;
17987      --surface-3: #efe3d5;
17988      --line: #dfcfbf;
17989      --line-strong: #cfb29c;
17990      --text: #2f241c;
17991      --muted: #6f6257;
17992      --muted-2: #917f71;
17993      --nav: #b85d33;
17994      --nav-2: #7a371b;
17995      --accent: #2563eb;
17996      --accent-2: #1d4ed8;
17997      --oxide: #b85d33;
17998      --oxide-2: #8f4220;
17999      --success-bg: #eaf9ee;
18000      --success-text: #1c8746;
18001      --warn-bg: #fff2d8;
18002      --warn-text: #926000;
18003      --danger-bg: #fdeaea;
18004      --danger-text: #b33b3b;
18005      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18006      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18007      --radius: 14px;
18008    }
18009
18010    body.dark-theme {
18011      --bg: #1b1511;
18012      --surface: #261c17;
18013      --surface-2: #2d221d;
18014      --surface-3: #372922;
18015      --line: #524238;
18016      --line-strong: #6c5649;
18017      --text: #f5ece6;
18018      --muted: #c7b7aa;
18019      --muted-2: #aa9485;
18020      --nav: #b85d33;
18021      --nav-2: #7a371b;
18022      --accent: #6f9bff;
18023      --accent-2: #4a78ee;
18024      --oxide: #d37a4c;
18025      --oxide-2: #b35428;
18026      --success-bg: #163927;
18027      --success-text: #8fe2a8;
18028      --warn-bg: #3c2d11;
18029      --warn-text: #f3cb75;
18030      --danger-bg: #3d1f1f;
18031      --danger-text: #ff9f9f;
18032      --shadow: 0 14px 28px rgba(0,0,0,0.28);
18033      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18034    }
18035
18036    * { box-sizing: border-box; }
18037    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); }
18038    html { overflow-y: scroll; }
18039    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18040    .top-nav, .page, .loading { position: relative; z-index: 2; }
18041    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18042    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18043    .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); }
18044    .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; }
18045    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18046    .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)); }
18047    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18048    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18049    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18050    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18051    .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; }
18052    .nav-project-pill.visible { display:inline-flex; }
18053    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18054    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18055    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18056    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18057    @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; } }
18058    .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; }
18059    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18060    .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; }
18061    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18062    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18063    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18064    .theme-toggle .icon-sun { display:none; }
18065    body.dark-theme .theme-toggle .icon-sun { display:block; }
18066    body.dark-theme .theme-toggle .icon-moon { display:none; }
18067    .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;}
18068    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18069    .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);}
18070    .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;}
18071    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18072    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18073    .settings-modal-body{padding:14px 16px 16px;}
18074    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18075    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18076    .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;}
18077    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18078    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18079    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18080    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18081    .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;}
18082    .tz-select:focus{border-color:var(--oxide);}
18083    .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; }
18084    .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;}
18085    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18086    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18087    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18088    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18089    .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; }
18090    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18091    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18092    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18093    .wb-stats-header { padding: 10px 24px 0; }
18094    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18095    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18096    .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; }
18097    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18098    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18099    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18100    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18101    .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; }
18102    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18103    .ws-stat-analyzers { position: relative; }
18104    .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; }
18105    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18106    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18107    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18108    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18109    .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; }
18110    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18111    .ws-divider { display: none; }
18112    .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%; }
18113    .ws-path-link:hover { color:var(--oxide); }
18114    body.dark-theme .ws-path-link { color:var(--oxide); }
18115    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18116    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18117    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18118    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18119    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18120    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18121    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18122    .ws-mini-box-lg { flex:2 1 0; }
18123    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18124    .ws-mini-box-br { flex:1.5 1 0; }
18125    .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; }
18126    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18127    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18128    #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; }
18129    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18130    .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; }
18131    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18132    .git-source-banner strong { font-weight:800; color:var(--text); }
18133    .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; }
18134    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18135    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18136    .git-source-banner a:hover { text-decoration:underline; }
18137    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18138    .path-scope-sep { background:var(--line); margin:4px 14px; }
18139    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18140    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18141    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18142    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18143    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18144    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18145    .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; }
18146    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18147    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18148    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18149    .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; }
18150    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18151    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18152    [data-wb-tip] { cursor:help; }
18153    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18154    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18155    .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; }
18156    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18157    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18158    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18159    .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; }
18160    .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); }
18161    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18162    .side-info-card { padding: 18px; }
18163    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18164    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18165    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18166    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18167    .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); }
18168    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18169    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18170    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18171    .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; }
18172    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18173    .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; }
18174    .side-stack::-webkit-scrollbar { display: none; }
18175    .step-nav { padding: 20px 16px; }
18176    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18177    .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; }
18178    .step-button:hover { background: var(--surface-2); }
18179    .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); }
18180    .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; }
18181    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18182    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18183    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18184    .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); }
18185    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18186    .step-nav-sum-row:last-child { border-bottom:none; }
18187    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18188    .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; }
18189    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18190    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18191    .quick-scan-section { padding: 10px 4px 14px; }
18192    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18193    .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; }
18194    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18195    .quick-scan-btn:active { transform:translateY(0); }
18196    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18197    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18198    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18199    @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);} }
18200    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18201    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18202    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18203    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18204    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18205    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18206    .step-button.done .step-check { opacity:1; }
18207    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18208    .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; }
18209    .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; }
18210    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18211    .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; }
18212    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18213    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18214    .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; }
18215    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18216    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18217    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18218    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18219    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18220    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18221    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18222    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18223    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18224    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18225    .card-body { padding: 22px; }
18226    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18227    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18228    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18229    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18230    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18231    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18232    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18233    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18234    .field { min-width:0; }
18235    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18236    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; }
18237    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); }
18238    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18239    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); }
18240    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18241    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18242    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18243    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18244    .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; }
18245    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18246    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18247    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18248    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18249    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18250    .input-group.compact { grid-template-columns: 1fr auto auto; }
18251    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18252    .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)); }
18253    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18254    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18255    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18256    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18257    .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; }
18258    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18259    .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; }
18260    .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); }
18261    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18262    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18263    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18264    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18265    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18266    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18267    @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; } }
18268    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18269    button.secondary { background: var(--surface); }
18270    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18271    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18272    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18273    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18274    .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); }
18275    .section + .wizard-actions { border-top: none; padding-top: 0; }
18276    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18277    .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; }
18278    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18279    .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; }
18280    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18281    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18282    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18283    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18284    .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); }
18285    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18286    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18287    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18288    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18289    .field-help-grid.coupled-help { margin-top: 12px; }
18290    .field-help-grid.preset-grid { align-items: start; }
18291    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18292    .preset-inline-row .field { margin: 0; }
18293    .preset-inline-row .explainer-card { margin: 0; }
18294    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18295    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18296    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18297    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18298    .preset-kv-row > :last-child { flex:1; min-width:0; }
18299    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18300    .output-field-row .field { margin: 0; }
18301    .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; }
18302    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18303    .step3-subtitle { margin-bottom: 10px; max-width: none; }
18304    .counting-intro { margin-bottom: 8px; max-width: none; }
18305    .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; }
18306    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18307    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18308    .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; }
18309    .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; }
18310    .section-spacer-top { margin-top: 28px; }
18311    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18312    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18313    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18314    .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); }
18315    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18316    .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; }
18317    .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; }
18318    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18319    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18320    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18321    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18322    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18323    .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; }
18324    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18325    .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); }
18326    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; }
18327    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; }
18328    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18329    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18330    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18331    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18332    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18333    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18334    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18335    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18336    .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); }
18337    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18338    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18339    .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; }
18340    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18341    .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; }
18342    .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; }
18343    .always-tracked-tip-body { flex:1; min-width:0; }
18344    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18345    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18346    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18347    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18348    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18349    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18350    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18351    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18352    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18353    .advanced-rule-description strong { color: var(--text); }
18354    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18355    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18356    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18357    .review-link:hover { text-decoration: underline; }
18358    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18359    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18360    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18361    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18362    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18363    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18364    .review-card ul { padding-left: 18px; margin: 0; }
18365    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18366    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18367    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18368    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18369    .review-card { min-height: 0; }
18370    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18371    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18372    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18373    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18374    .lang-overflow-chip { position:relative; cursor:default; }
18375    .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; }
18376    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18377    .git-inline-row { align-items:start; }
18378    .mixed-line-card { display:flex; flex-direction:column; }
18379    .preset-inline-row .toggle-card { justify-content: center; }
18380        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18381    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18382    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18383    .explorer-title { font-size: 18px; font-weight: 850; }
18384    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18385    .explorer-subtitle.wide { max-width: none; }
18386    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18387    .better-spacing { align-items:flex-start; justify-content:flex-end; }
18388    .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; }
18389    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18390    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18391    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18392    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18393    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18394    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18395    .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; }
18396    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18397    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18398    .scope-stat-button.supported { background: var(--success-bg); }
18399    .scope-stat-button.skipped { background: var(--warn-bg); }
18400    .scope-stat-button.unsupported { background: var(--danger-bg); }
18401    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18402    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18403    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18404    [data-tooltip] { position: relative; }
18405    [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); }
18406    [data-tooltip]:hover::after { display: block; }
18407    .scope-stat-button[data-tooltip] { cursor: pointer; }
18408    .badge[data-tooltip] { cursor: help; }
18409    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18410    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18411    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18412    .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; }
18413    .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; }
18414    code { display:inline-block; margin-top:0; padding:2px 7px; }
18415    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18416    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18417    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18418    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18419    .language-pill.muted-pill { color: var(--muted); }
18420    button.language-pill { appearance:none; cursor:pointer; }
18421    .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); }
18422    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18423    .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; }
18424    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18425    .file-explorer-search-row { margin-left: auto; }
18426    .explorer-filter-select { min-width: 170px; width: 170px; }
18427    .explorer-search { min-width: 300px; width: 300px; }
18428    .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); }
18429    .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; }
18430    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18431    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18432    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18433    .file-explorer-tree { max-height: 640px; overflow:auto; }
18434    .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); }
18435    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18436    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18437    .tree-row.hidden-by-filter { display:none !important; }
18438    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18439    .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; }
18440    .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; }
18441    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18442    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18443    .tree-node { display:inline-flex; align-items:center; min-width:0; }
18444    .tree-node-dir { color: var(--text); font-weight: 800; }
18445    .tree-node-supported { color: var(--success-text); }
18446    .tree-node-skipped { color: var(--warn-text); }
18447    .tree-node-unsupported { color: var(--danger-text); }
18448    .tree-node-more { color: var(--muted-2); font-style: italic; }
18449    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18450    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18451    .tree-status-cell { display:flex; justify-content:flex-start; }
18452    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18453    .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; }
18454    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18455    .preview-warning p { margin: 0 0 10px; }
18456    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18457    .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; }
18458    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18459    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18460    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18461    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18462    .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; }
18463    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18464    .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; }
18465    @keyframes prevSpin { to { transform:rotate(360deg); } }
18466    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18467    .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; }
18468    .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; }
18469    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18470    .preview-gate-info svg { width:16px; height:16px; }
18471    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18472    @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); } }
18473    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18474    .preview-loading-text { flex:1; min-width:0; }
18475    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18476    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18477    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18478    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18479    .cov-scan-idle { display:none; }
18480    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18481    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18482    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18483    .cov-scan-title { font-weight:600; font-size:12.5px; }
18484    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18485    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18486    .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; }
18487    .cov-scan-use:hover { opacity:.75; }
18488    .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; }
18489    .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; }
18490    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18491    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18492    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18493    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18494    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18495    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18496    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18497    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18498    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18499    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18500    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18501    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18502    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18503    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18504    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18505    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18506    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18507    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18508    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18509    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18510    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18511    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18512    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18513    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18514    .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); }
18515    .loading.active { display:flex; }
18516    /* Lock page scroll while the analysis modal is open so the removed scrollbar
18517       gutter doesn't pull the centered card slightly left of true center. */
18518    body.modal-open { overflow: hidden; }
18519    .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; }
18520    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18521    .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; }
18522    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18523    .loading-card > * { position:relative; z-index:1; }
18524    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18525    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%); }
18526    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18527    .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; }
18528    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18529    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18530    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18531    .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; }
18532    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18533    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18534    .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; }
18535    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18536    .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; }
18537    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18538    .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; }
18539    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18540    .lc-step.done { color:var(--muted);opacity:0.55; }
18541    .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; }
18542    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18543    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18544    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18545    .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; }
18546    .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; }
18547    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18548    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18549    .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; }
18550    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18551    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18552    .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; }
18553    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18554    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18555    .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; }
18556    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18557    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18558    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18559    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18560    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18561    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18562    .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; }
18563    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18564    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18565    .hidden { display:none !important; }
18566    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18567    .site-footer a{color:var(--muted);}
18568    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18569    @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; } }
18570    .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;}
18571    @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));}}
18572    .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;}
18573    .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; }
18574    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18575    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18576    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18577    .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; }
18578    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18579    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18580    .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; }
18581    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18582    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18583    .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; }
18584    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18585    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18586    .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; }
18587    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18588    .info-icon-btn:hover { color:var(--text); }
18589    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); }
18590    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18591    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18592    .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;}
18593    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18594    .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;}
18595    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18596    #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);}
18597    #offline-file-banner.show{display:flex;}
18598    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18599    #offline-file-banner .ofb-text{flex:1;}
18600    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18601    #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;}
18602    #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;}
18603    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18604    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18605    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18606    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18607    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18608    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18609    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18610  </style>
18611</head>
18612<body id="page-top">
18613  <div id="offline-file-banner" role="alert">
18614    <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>
18615    <span class="ofb-text">
18616      Charts, images, and navigation require the oxide-sloc server.
18617      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18618      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18619      The metric tables below are fully readable without the server.
18620    </span>
18621    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18622  </div>
18623  <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>
18624  <div class="background-watermarks" aria-hidden="true">
18625    <img src="/images/logo/logo-text.png" alt="" />
18626    <img src="/images/logo/logo-text.png" alt="" />
18627    <img src="/images/logo/logo-text.png" alt="" />
18628    <img src="/images/logo/logo-text.png" alt="" />
18629    <img src="/images/logo/logo-text.png" alt="" />
18630    <img src="/images/logo/logo-text.png" alt="" />
18631    <img src="/images/logo/logo-text.png" alt="" />
18632    <img src="/images/logo/logo-text.png" alt="" />
18633    <img src="/images/logo/logo-text.png" alt="" />
18634    <img src="/images/logo/logo-text.png" alt="" />
18635    <img src="/images/logo/logo-text.png" alt="" />
18636    <img src="/images/logo/logo-text.png" alt="" />
18637    <img src="/images/logo/logo-text.png" alt="" />
18638    <img src="/images/logo/logo-text.png" alt="" />
18639  </div>
18640  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18641  <div class="top-nav">
18642    <div class="top-nav-inner">
18643      <a class="brand" href="/">
18644        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18645        <div class="brand-copy">
18646          <div class="brand-title">OxideSLOC</div>
18647          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18648        </div>
18649      </a>
18650      <div class="nav-project-slot">
18651        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18652          <span class="nav-project-label">Project</span>
18653          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18654        </div>
18655      </div>
18656      <div class="nav-status">
18657        <a class="nav-pill" href="/">Home</a>
18658        <div class="nav-dropdown">
18659          <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>
18660          <div class="nav-dropdown-menu">
18661            <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>
18662          </div>
18663        </div>
18664        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18665        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18666        <div class="nav-dropdown">
18667          <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>
18668          <div class="nav-dropdown-menu">
18669            <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>
18670          </div>
18671        </div>
18672        <div class="server-status-wrap" id="server-status-wrap">
18673          <div class="nav-pill server-online-pill" id="server-status-pill">
18674            <span class="status-dot" id="status-dot"></span>
18675            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18676            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18677          </div>
18678          <div class="server-status-tip">
18679            {% if server_mode %}
18680            OxideSLOC is running in server mode — accessible on your LAN.
18681            {% else %}
18682            OxideSLOC is running locally — only accessible from this machine.
18683            {% endif %}
18684            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18685          </div>
18686        </div>
18687        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18688          <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>
18689        </button>
18690        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18691          <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>
18692          <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>
18693        </button>
18694      </div>
18695    </div>
18696  </div>
18697
18698  <div class="loading" id="loading">
18699    <div class="loading-card" id="loading-card">
18700      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18701      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18702      <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>
18703      <div class="lc-steps" id="lc-steps">
18704        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18705        <div class="lc-step-arrow">›</div>
18706        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18707        <div class="lc-step-arrow">›</div>
18708        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18709        <div class="lc-step-arrow">›</div>
18710        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18711      </div>
18712      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18713      <div class="lc-metrics" id="lc-metrics">
18714        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18715        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18716        <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>
18717        <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>
18718      </div>
18719      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18720      <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>
18721      <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>
18722      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18723      <div class="lc-actions hidden" id="lc-actions">
18724        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18725        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18726      </div>
18727      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18728        <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>
18729        Cancel scan
18730      </button>
18731    </div>
18732  </div>
18733
18734  <div class="page">
18735    <div class="workbench-strip">
18736      <div class="workbench-box wb-stats">
18737        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18738          <span class="wb-stats-title">Analysis session</span>
18739        </div>
18740        <div class="ws-left">
18741          <div class="ws-stat ws-stat-analyzers">
18742            <span class="ws-label">Analyzers</span>
18743            <span class="ws-value">
18744              <span class="ws-badge">60 languages</span>
18745            </span>
18746            <div class="ws-lang-tooltip">
18747              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18748              <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>
18749              <div class="ws-lang-grid">
18750                <span class="ws-lang-item">Assembly</span>
18751                <span class="ws-lang-item">C</span>
18752                <span class="ws-lang-item">C++</span>
18753                <span class="ws-lang-item">C#</span>
18754                <span class="ws-lang-item">Clojure</span>
18755                <span class="ws-lang-item">CSS</span>
18756                <span class="ws-lang-item">Dart</span>
18757                <span class="ws-lang-item">Dockerfile</span>
18758                <span class="ws-lang-item">Elixir</span>
18759                <span class="ws-lang-item">Erlang</span>
18760                <span class="ws-lang-item">F#</span>
18761                <span class="ws-lang-item">Go</span>
18762                <span class="ws-lang-item">Groovy</span>
18763                <span class="ws-lang-item">Haskell</span>
18764                <span class="ws-lang-item">HTML</span>
18765                <span class="ws-lang-item">Java</span>
18766                <span class="ws-lang-item">JavaScript</span>
18767                <span class="ws-lang-item">Julia</span>
18768                <span class="ws-lang-item">Kotlin</span>
18769                <span class="ws-lang-item">Lua</span>
18770                <span class="ws-lang-item">Makefile</span>
18771                <span class="ws-lang-item">Nim</span>
18772                <span class="ws-lang-item">Obj-C</span>
18773                <span class="ws-lang-item">OCaml</span>
18774                <span class="ws-lang-item">Perl</span>
18775                <span class="ws-lang-item">PHP</span>
18776                <span class="ws-lang-item">PowerShell</span>
18777                <span class="ws-lang-item">Python</span>
18778                <span class="ws-lang-item">R</span>
18779                <span class="ws-lang-item">Ruby</span>
18780                <span class="ws-lang-item">Rust</span>
18781                <span class="ws-lang-item">Scala</span>
18782                <span class="ws-lang-item">SCSS</span>
18783                <span class="ws-lang-item">Shell</span>
18784                <span class="ws-lang-item">SQL</span>
18785                <span class="ws-lang-item">Svelte</span>
18786                <span class="ws-lang-item">Swift</span>
18787                <span class="ws-lang-item">TypeScript</span>
18788                <span class="ws-lang-item">Vue</span>
18789                <span class="ws-lang-item">XML</span>
18790                <span class="ws-lang-item">Zig</span>
18791                <span class="ws-lang-item">Solidity</span>
18792                <span class="ws-lang-item">Protobuf</span>
18793                <span class="ws-lang-item">HCL</span>
18794                <span class="ws-lang-item">GraphQL</span>
18795                <span class="ws-lang-item">Ada</span>
18796                <span class="ws-lang-item">VHDL</span>
18797                <span class="ws-lang-item">Verilog</span>
18798                <span class="ws-lang-item">Tcl</span>
18799                <span class="ws-lang-item">Pascal</span>
18800                <span class="ws-lang-item">Visual Basic</span>
18801                <span class="ws-lang-item">Lisp</span>
18802                <span class="ws-lang-item">Fortran</span>
18803                <span class="ws-lang-item">Nix</span>
18804                <span class="ws-lang-item">Crystal</span>
18805                <span class="ws-lang-item">D</span>
18806                <span class="ws-lang-item">GLSL</span>
18807                <span class="ws-lang-item">CMake</span>
18808                <span class="ws-lang-item">Elm</span>
18809                <span class="ws-lang-item">Awk</span>
18810              </div>
18811            </div>
18812          </div>
18813          <div class="ws-divider"></div>
18814          <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>
18815          <div class="ws-divider"></div>
18816          <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.">
18817            <span class="ws-label">Output</span>
18818            <span class="ws-value">
18819              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18820                <span id="ws-output-root">project/sloc</span>
18821              </button>
18822            </span>
18823          </div>
18824        </div>
18825      </div>
18826      <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.">
18827        <div class="ws-history-label">Scan history</div>
18828        <div class="ws-history-inner">
18829          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18830            <div class="ws-mini-label">Scans</div>
18831            <div class="ws-mini-value" id="ws-scan-count">—</div>
18832          </div>
18833          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18834            <div class="ws-mini-label">Last Scan</div>
18835            <div class="ws-mini-value" id="ws-last-scan">—</div>
18836          </div>
18837          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18838            <div class="ws-mini-label">Branch</div>
18839            <div class="ws-mini-value" id="ws-branch">—</div>
18840          </div>
18841        </div>
18842      </div>
18843    </div>
18844
18845    <div class="layout">
18846      <aside class="side-stack">
18847        <section class="step-nav">
18848        <h3>Guided scan setup</h3>
18849        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18850          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18851          Top of page
18852        </a>
18853        <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>
18854        <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>
18855        <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>
18856        <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>
18857
18858        <div class="step-steps-divider"></div>
18859
18860        <div class="step-nav-info" id="step-nav-info">
18861          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18862          <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>
18863        </div>
18864
18865        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18866          <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>
18867          <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>
18868          <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>
18869        </div>
18870
18871        <div class="quick-scan-divider"></div>
18872        <div class="quick-scan-section">
18873          <div class="quick-scan-label">No customization needed?</div>
18874          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18875            <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>
18876            Quick Scan
18877          </button>
18878          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
18879        </div>
18880
18881        <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>
18882        <div class="sidebar-scroll-divider"></div>
18883        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18884          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18885          Skip to bottom
18886        </a>
18887        </section>
18888
18889      </aside>
18890
18891      <section class="card">
18892        <div class="card-header">
18893          <div class="card-title-row">
18894            <div>
18895              <h1 class="card-title">Guided scan configuration</h1>
18896              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18897            </div>
18898            <div class="wizard-progress" aria-label="Scan setup progress">
18899              <div class="wizard-progress-top">
18900                <span class="wizard-progress-label">Setup progress</span>
18901                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18902              </div>
18903              <div class="wizard-progress-track">
18904                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18905              </div>
18906            </div>
18907          </div>
18908        </div>
18909        <div class="card-body">
18910          <form method="post" action="/analyze" id="analyze-form">
18911            <div class="wizard-step active" data-step="1">
18912              <div class="section">
18913                <div class="section-kicker">Step 1</div>
18914                <h2>Select project and preview scope</h2>
18915                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18916                <div class="field">
18917                  <label for="path">Project path</label>
18918                  {% if !git_repo.is_empty() %}
18919                  <div class="git-source-banner">
18920                    <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>
18921                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18922                    <a href="/git-browser">← Back to Git Browser</a>
18923                  </div>
18924                  {% endif %}
18925                  <div class="path-scope-grid">
18926                      {% if !git_repo.is_empty() %}
18927                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18928                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18929                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18930                      {% else %}
18931                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18932                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18933                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18934                      {% endif %}
18935                    <div class="path-scope-sep"></div>
18936                    <div class="scope-legend-row">
18937                      <span class="scope-legend-label">Scope legend:</span>
18938                      <span class="scope-legend-badges">
18939                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18940                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18941                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18942                      </span>
18943                    </div>
18944                  </div>
18945                  {% if git_repo.is_empty() %}
18946                  {% if server_mode %}
18947                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18948                    ℹ️ Files are compressed and streamed — no fixed size limit.
18949                  </div>
18950                  {% endif %}
18951                  <div class="path-info-row">
18952                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18953                      <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>
18954                      <span id="project-size-text">Project size: —</span>
18955                    </button>
18956                  </div>
18957                  {% else %}
18958                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18959                  {% endif %}
18960                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18961                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18962                </div>
18963
18964                <div class="scope-preview-divider" aria-hidden="true"></div>
18965
18966                <div id="preview-panel">
18967                  <div class="preview-error">Loading preview...</div>
18968                </div>
18969              </div>
18970
18971              <div class="section" style="margin-top:14px;">
18972                <div class="preset-inline-row git-inline-row">
18973                  <div class="toggle-card" style="margin:0;">
18974                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18975                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18976                    <label class="checkbox">
18977                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18978                      <div>
18979                        <span>Detect and separate git submodules</span>
18980                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18981                      </div>
18982                    </label>
18983                  </div>
18984                  <div class="explainer-card prominent" style="margin:0;">
18985                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18986                    <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>
18987                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18988    path = libs/core
18989    url  = https://github.com/org/core.git
18990
18991[submodule "libs/ui"]
18992    path = libs/ui
18993    url  = https://github.com/org/ui.git</div>
18994                  </div>
18995                </div>
18996              </div>
18997
18998              <div class="section">
18999                <div class="field-grid">
19000                  <div class="field">
19001                    <div class="glob-label-row">
19002                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19003                      <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>
19004                    </div>
19005                    <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>
19006                    <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>
19007                  </div>
19008                  <div class="field">
19009                    <div class="glob-label-row">
19010                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19011                    </div>
19012                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
19013                    <div id="quick-exclude-chips" class="quick-excl-row">
19014                      <span class="quick-excl-label">Quick add:</span>
19015                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19016                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19017                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19018                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19019                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19020                      <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>
19021                    </div>
19022                    <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>
19023                  </div>
19024                </div>
19025                <div class="glob-guidance-grid">
19026                  <div class="glob-guidance-card">
19027                    <strong>How to read them</strong>
19028                    <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>
19029                  </div>
19030                  <div class="glob-guidance-card">
19031                    <strong>Common include examples</strong>
19032                    <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>
19033                  </div>
19034                  <div class="glob-guidance-card">
19035                    <strong>Common exclude examples</strong>
19036                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19037                  </div>
19038                </div>
19039              </div>
19040
19041              <div class="section" style="margin-top:14px;">
19042                <div class="preset-inline-row git-inline-row">
19043                  <div class="toggle-card" style="margin:0;">
19044                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19045                    <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>
19046                    <div class="field" style="margin:0;">
19047                      <div class="input-group compact">
19048                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19049                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19050                      </div>
19051                      <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>
19052                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19053                    </div>
19054                  </div>
19055                  <div class="explainer-card prominent" style="margin:0;">
19056                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19057                    <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>
19058                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19059lcov --capture --directory . --output-file coverage/lcov.info
19060
19061# C / C++ — llvm-cov (LCOV)
19062llvm-profdata merge -sparse default.profraw -o default.profdata
19063llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19064
19065# C# — coverlet (Cobertura XML)
19066dotnet test --collect:"XPlat Code Coverage"
19067
19068# Python — pytest-cov (Cobertura XML)
19069pytest --cov --cov-report=xml
19070
19071# Python — coverage.py native JSON
19072coverage run -m pytest && coverage json   # writes coverage.json
19073
19074# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19075./gradlew jacocoTestReport</div>
19076                  </div>
19077                </div>
19078              </div>
19079
19080              <div class="wizard-actions">
19081                <div class="left"></div>
19082                <div class="right">
19083                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19084                    <span class="preview-gate-spinner" aria-hidden="true"></span>
19085                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
19086                    <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">
19087                      <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>
19088                    </button>
19089                  </div>
19090                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19091                </div>
19092              </div>
19093            </div>
19094
19095            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19096              <div class="default-path-modal">
19097                <h3 id="default-path-title">
19098                  <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>
19099                  Proceed with the default sample test?
19100                </h3>
19101                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19102                <p>You haven&#39;t selected your own project yet.</p>
19103                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19104                <div class="default-path-actions">
19105                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19106                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19107                </div>
19108              </div>
19109            </div>
19110
19111            <div class="wizard-step" data-step="2">
19112              <div class="section">
19113                <div class="section-kicker">Step 2</div>
19114                <h2>Choose counting behavior</h2>
19115                <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>
19116<div class="subsection-bar">Primary line classification</div>
19117                <div class="preset-kv-row">
19118                  <div class="toggle-card mixed-line-card" style="margin:0;">
19119                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19120                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19121                    <select id="mixed_line_policy" name="mixed_line_policy">
19122                      <option value="code_only">Code only</option>
19123                      <option value="code_and_comment">Code and comment</option>
19124                      <option value="comment_only">Comment only</option>
19125                      <option value="separate_mixed_category">Separate mixed category</option>
19126                    </select>
19127                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19128                  </div>
19129                  <div class="explainer-card prominent" style="margin:0;">
19130                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19131                    <div class="explainer-body" id="mixed-policy-description"></div>
19132                    <div class="code-sample" id="mixed-policy-example"></div>
19133                  </div>
19134                </div>
19135              </div>
19136
19137              <div class="subsection-bar">Additional scan rules</div>
19138              <div class="scan-rules-grid">
19139                <div class="preset-inline-row">
19140                  <div class="toggle-card" style="margin:0;">
19141                    <div class="field-help-title">Generated files</div>
19142                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19143                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19144                  </div>
19145                  <div class="explainer-card prominent" style="margin:0;">
19146                    <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>
19147                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19148# Files matching codegen patterns are excluded:
19149#   *.generated.cs  *.pb.go  *.g.dart</div>
19150                  </div>
19151                </div>
19152                <div class="preset-inline-row">
19153                  <div class="toggle-card" style="margin:0;">
19154                    <div class="field-help-title">Minified files</div>
19155                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19156                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19157                  </div>
19158                  <div class="explainer-card prominent" style="margin:0;">
19159                    <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>
19160                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19161# Heuristic: very long lines + low whitespace ratio
19162#   jquery.min.js  bundle.min.css  → skipped</div>
19163                  </div>
19164                </div>
19165                <div class="preset-inline-row">
19166                  <div class="toggle-card" style="margin:0;">
19167                    <div class="field-help-title">Vendor directories</div>
19168                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19169                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19170                  </div>
19171                  <div class="explainer-card prominent" style="margin:0;">
19172                    <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>
19173                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19174# Directories named vendor/ node_modules/ third_party/
19175#   → entire subtree is excluded from totals</div>
19176                  </div>
19177                </div>
19178                <div class="preset-inline-row">
19179                  <div class="toggle-card" style="margin:0;">
19180                    <div class="field-help-title">Lockfiles and manifests</div>
19181                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19182                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19183                  </div>
19184                  <div class="explainer-card prominent" style="margin:0;">
19185                    <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>
19186                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
19187# Files like package-lock.json  Cargo.lock  yarn.lock
19188#   → skipped unless this is enabled</div>
19189                  </div>
19190                </div>
19191                <div class="preset-inline-row">
19192                  <div class="toggle-card" style="margin:0;">
19193                    <div class="field-help-title">Binary handling</div>
19194                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19195                    <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>
19196                  </div>
19197                  <div class="explainer-card prominent" style="margin:0;">
19198                    <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>
19199                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
19200# Detected via long lines + low whitespace heuristic
19201#   .png  .exe  .so  → skipped silently</div>
19202                  </div>
19203                </div>
19204                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19205                  <div class="toggle-card" style="margin:0;">
19206                    <div class="field-help-title">Python docstrings</div>
19207                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19208                    <label class="checkbox">
19209                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19210                      <span>Count as comment-style lines</span>
19211                    </label>
19212                  </div>
19213                  <div class="explainer-card prominent" style="margin:0;">
19214                    <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>
19215                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19216                  </div>
19217                </div>
19218              </div>
19219              <div class="subsection-bar">IEEE 1045-1992 counting</div>
19220              <div class="scan-rules-grid">
19221                <div class="preset-inline-row">
19222                  <div class="toggle-card" style="margin:0;">
19223                    <div class="field-help-title">Continuation lines</div>
19224                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19225                    <select name="continuation_line_policy" id="continuation_line_policy">
19226                      <option value="each_physical_line" selected>Each physical line (default)</option>
19227                      <option value="collapse_to_logical">Collapse to logical line</option>
19228                    </select>
19229                  </div>
19230                  <div class="explainer-card prominent" style="margin:0;">
19231                    <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>
19232                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19233    ((a) &gt; (b) ? (a) : (b))
19234# each_physical_line → 2 SLOC
19235# collapse_to_logical → 1 SLOC</div>
19236                  </div>
19237                </div>
19238                <div class="preset-inline-row">
19239                  <div class="toggle-card" style="margin:0;">
19240                    <div class="field-help-title">Block-comment blanks</div>
19241                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19242                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19243                      <option value="count_as_comment" selected>Count as comment (default)</option>
19244                      <option value="count_as_blank">Count as blank</option>
19245                    </select>
19246                  </div>
19247                  <div class="explainer-card prominent" style="margin:0;">
19248                    <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>
19249                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19250 * Summary line
19251 *              ← blank inside block comment
19252 * Detail line
19253 */
19254# count_as_comment → blank counts toward comments
19255# count_as_blank   → blank counts toward blanks</div>
19256                  </div>
19257                </div>
19258                <div class="preset-inline-row">
19259                  <div class="toggle-card" style="margin:0;">
19260                    <div class="field-help-title">Compiler directives</div>
19261                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19262                    <select name="count_compiler_directives" id="count_compiler_directives">
19263                      <option value="enabled" selected>Include in code SLOC (default)</option>
19264                      <option value="disabled">Exclude from code SLOC</option>
19265                    </select>
19266                  </div>
19267                  <div class="explainer-card prominent" style="margin:0;">
19268                    <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>
19269                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
19270#define BUF 256     ← compiler directive
19271int main() { … }   ← code
19272# enabled  → 3 code SLOC
19273# disabled → 1 code SLOC + 2 directive lines</div>
19274                  </div>
19275                </div>
19276              </div>
19277
19278              <div class="subsection-bar">Code Style Analysis</div>
19279              <div class="scan-rules-grid">
19280                <div class="preset-inline-row">
19281                  <div class="toggle-card" style="margin:0;">
19282                    <div class="field-help-title">Style analysis</div>
19283                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19284                    <select name="style_analysis_enabled" id="style_analysis_enabled">
19285                      <option value="enabled" selected>Enabled (default)</option>
19286                      <option value="disabled">Disabled — skip style scoring</option>
19287                    </select>
19288                  </div>
19289                  <div class="explainer-card prominent" style="margin:0;">
19290                    <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>
19291                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
19292# style_analysis_enabled = false  (skip, faster scan)
19293# Disabling removes the Code Style section from the report.</div>
19294                  </div>
19295                </div>
19296                <div class="preset-inline-row">
19297                  <div class="toggle-card" style="margin:0;">
19298                    <div class="field-help-title">Column-width threshold</div>
19299                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19300                    <select name="style_col_threshold" id="style_col_threshold">
19301                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19302                      <option value="100">100 columns (Uber Go, Google Java)</option>
19303                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
19304                    </select>
19305                  </div>
19306                  <div class="explainer-card prominent" style="margin:0;">
19307                    <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>
19308                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
19309# style_col_threshold = 100 (Uber Go, Google Java)
19310# style_col_threshold = 120 (Uber Go max, Kotlin)
19311# Files where &lt;= 5% of lines exceed the limit
19312# are counted as "N-col compliant" in the report.</div>
19313                  </div>
19314                </div>
19315                <div class="preset-inline-row">
19316                  <div class="toggle-card" style="margin:0;">
19317                    <div class="field-help-title">Score alert threshold</div>
19318                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19319                    <select name="style_score_threshold" id="style_score_threshold">
19320                      <option value="0" selected>Off — no threshold (default)</option>
19321                      <option value="40">40% — flag poorly styled files</option>
19322                      <option value="50">50% — flag below-average files</option>
19323                      <option value="60">60% — flag below-good files</option>
19324                      <option value="70">70% — flag below-strong files</option>
19325                    </select>
19326                  </div>
19327                  <div class="explainer-card prominent" style="margin:0;">
19328                    <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>
19329                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
19330# style_score_threshold = 50  (flag files &lt; 50%)
19331# Low-scoring files get a red left-border in the
19332# per-file style breakdown table.</div>
19333                  </div>
19334                </div>
19335              </div>
19336
19337              <div class="always-tracked-tip">
19338                <div class="always-tracked-tip-icon">ℹ</div>
19339                <div class="always-tracked-tip-body">
19340                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
19341                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
19342                  <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>
19343                </div>
19344              </div>
19345
19346              <div class="subsection-bar">Advanced Metrics</div>
19347              <div class="scan-rules-grid">
19348                <div class="preset-inline-row">
19349                  <div class="toggle-card" style="margin:0;">
19350                    <div class="field-help-title">COCOMO mode</div>
19351                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19352                    <select name="cocomo_mode" id="cocomo_mode">
19353                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19354                      <option value="semi_detached">Semi-detached — mixed constraints</option>
19355                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
19356                    </select>
19357                  </div>
19358                  <div class="explainer-card prominent" style="margin:0;">
19359                    <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>
19360                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
19361# Semi-detached: Effort = 3.0 × KSLOC^1.12
19362# Embedded:     Effort = 3.6 × KSLOC^1.20
19363# All modes: Schedule = 2.5 × Effort^d</div>
19364                  </div>
19365                </div>
19366                <div class="preset-inline-row">
19367                  <div class="toggle-card" style="margin:0;">
19368                    <div class="field-help-title">Complexity alert</div>
19369                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19370                    <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;" />
19371                  </div>
19372                  <div class="explainer-card prominent" style="margin:0;">
19373                    <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>
19374                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19375# 50  = flag any file with &gt; 50 branch points
19376# 100 = flag any file with &gt; 100 branch points
19377# Files above the threshold are highlighted
19378# in the result page metric strip.</div>
19379                  </div>
19380                </div>
19381                <div class="preset-inline-row">
19382                  <div class="toggle-card" style="margin:0;">
19383                    <div class="field-help-title">Git hotspots</div>
19384                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19385                    <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;" />
19386                  </div>
19387                  <div class="explainer-card prominent" style="margin:0;">
19388                    <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>
19389                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
19390# 30  = last month of activity
19391# 365 = last year
19392# 0   = disable the hotspots table
19393# Adds Commits + Last-changed columns to CSV.</div>
19394                  </div>
19395                </div>
19396                <div class="preset-inline-row">
19397                  <div class="toggle-card" style="margin:0;">
19398                    <div class="field-help-title">Duplicate handling</div>
19399                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19400                    <select name="exclude_duplicates" id="exclude_duplicates">
19401                      <option value="disabled" selected>Detect and report only (default)</option>
19402                      <option value="enabled">Detect and exclude from SLOC totals</option>
19403                    </select>
19404                  </div>
19405                  <div class="explainer-card prominent" style="margin:0;">
19406                    <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>
19407                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19408# detect only   → all 3 counted in SLOC
19409# exclude dupes → 1 counted, 2 excluded
19410# Duplicate groups chip always shows the count.</div>
19411                  </div>
19412                </div>
19413                <div class="always-tracked-tip" style="margin:8px 0 0;">
19414                  <div class="always-tracked-tip-icon">ℹ</div>
19415                  <div class="always-tracked-tip-body">
19416                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
19417                    <div class="always-tracked-metrics-row">
19418                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19419                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
19420                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
19421                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
19422                    </div>
19423                    <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>
19424                  </div>
19425                </div>
19426              </div>
19427
19428              <div class="wizard-actions">
19429                <div class="left">
19430                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19431                </div>
19432                <div class="right">
19433                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19434                </div>
19435              </div>
19436            </div>
19437
19438            <div class="wizard-step" data-step="3">
19439              <div class="section">
19440                <div class="section-kicker">Step 3</div>
19441                <h2>Output and report identity</h2>
19442                <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>
19443                <div class="preset-kv-row">
19444                  <div class="toggle-card" style="margin:0;">
19445                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19446                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19447                    <select id="scan_preset">
19448                      <option value="balanced">Balanced local scan</option>
19449                      <option value="code_focused">Code focused</option>
19450                      <option value="comment_audit">Comment audit</option>
19451                      <option value="deep_review">Deep review</option>
19452                    </select>
19453                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19454                  </div>
19455                  <div class="explainer-card">
19456                    <div class="field-help-title">Selected scan preset</div>
19457                    <div class="explainer-body" id="scan-preset-description"></div>
19458                    <div class="preset-summary-row" id="scan-preset-summary"></div>
19459                    <div class="code-sample" id="scan-preset-example"></div>
19460                    <div class="preset-note" id="scan-preset-note"></div>
19461                  </div>
19462                </div>
19463                <hr class="step3-separator" />
19464                <div class="preset-kv-row">
19465                  <div class="toggle-card" style="margin:0;">
19466                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19467                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19468                    <select id="artifact_preset">
19469                      <option value="review">Review bundle</option>
19470                      <option value="full">Full bundle</option>
19471                      <option value="html_only">HTML only</option>
19472                      <option value="machine">Machine bundle</option>
19473                    </select>
19474                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19475                  </div>
19476                  <div class="explainer-card">
19477                    <div class="field-help-title">Selected artifact preset</div>
19478                    <div class="explainer-body" id="artifact-preset-description"></div>
19479                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
19480                    <div class="code-sample" id="artifact-preset-example"></div>
19481                  </div>
19482                </div>
19483              </div>
19484
19485              <div class="section section-spacer-top">
19486                <div class="output-field-row">
19487                  <div class="field">
19488                    <label for="output_dir">Output directory</label>
19489                    {% if server_mode %}
19490                    <div class="input-group compact">
19491                      <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);" />
19492                    </div>
19493                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19494                    {% else %}
19495                    <div class="input-group compact">
19496                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19497                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19498                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
19499                    </div>
19500                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19501                    {% endif %}
19502                  </div>
19503                  <div class="output-field-aside">
19504                    <strong>Where reports land</strong>
19505                    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.
19506                  </div>
19507                </div>
19508              </div>
19509
19510              <div class="section section-spacer-top">
19511                <div class="output-field-row">
19512                  <div class="field">
19513                    <label for="report_title">Report title</label>
19514                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19515                    <div class="hint">Appears in HTML and PDF output headers.</div>
19516                  </div>
19517                  <div class="output-field-aside">
19518                    <strong>Shown in exported artifacts</strong>
19519                    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.
19520                  </div>
19521                </div>
19522              </div>
19523
19524              <div class="section section-spacer-top">
19525                <div class="output-field-row">
19526                  <div class="field">
19527                    <label for="report_header_footer">Report header / footer</label>
19528                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19529                    <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>
19530                  </div>
19531                  <div class="output-field-aside">
19532                    <strong>Page-level identification</strong>
19533                    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.
19534                  </div>
19535                </div>
19536              </div>
19537
19538              <div class="wizard-actions">
19539                <div class="left">
19540                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19541                </div>
19542                <div class="right">
19543                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19544                </div>
19545              </div>
19546            </div>
19547
19548            <div class="wizard-step" data-step="4">
19549              <div class="section">
19550                <div class="section-kicker">Step 4</div>
19551                <h2>Review selections and run</h2>
19552                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19553                <div class="review-grid">
19554                  <div class="review-card highlight">
19555                    <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>
19556                    <ul id="review-scan-summary"></ul>
19557                  </div>
19558                  <div class="review-card highlight">
19559                    <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>
19560                    <ul id="review-count-summary"></ul>
19561                  </div>
19562                  <div class="review-card">
19563                    <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>
19564                    <ul id="review-artifact-summary"></ul>
19565                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19566                  </div>
19567                  <div class="review-card">
19568                    <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>
19569                    <ul id="review-preview-summary"></ul>
19570                  </div>
19571                </div>
19572              </div>
19573
19574              <div class="wizard-actions">
19575                <div class="left">
19576                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19577                </div>
19578                <div class="right">
19579                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
19580                </div>
19581              </div>
19582            </div>
19583            {% if server_mode %}
19584            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19585            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19586            {% endif %}
19587          </form>
19588        </div>
19589      </section>
19590    </div>
19591  </div>
19592
19593  <script nonce="{{ csp_nonce }}">
19594    (function () {
19595      function startScanPhase() {
19596        var phaseEl = document.getElementById("scan-phase");
19597        if (!phaseEl) return;
19598        var phases = [
19599          "Discovering files...",
19600          "Decoding file encodings...",
19601          "Detecting languages...",
19602          "Analyzing source lines...",
19603          "Applying counting policies...",
19604          "Aggregating results...",
19605          "Rendering report..."
19606        ];
19607        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19608        var i = 0;
19609        function next() {
19610          phaseEl.style.opacity = "0";
19611          setTimeout(function () {
19612            phaseEl.textContent = phases[i];
19613            phaseEl.style.opacity = "0.85";
19614            var delay = durations[i] || 1800;
19615            i++;
19616            if (i < phases.length) { setTimeout(next, delay); }
19617          }, 200);
19618        }
19619        next();
19620      }
19621
19622      var form = document.getElementById("analyze-form");
19623      var loading = document.getElementById("loading");
19624      var submitButton = document.getElementById("submit-button");
19625      var pathInput = document.getElementById("path");
19626      var GIT_MODE = !!(pathInput && pathInput.readOnly);
19627      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19628      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19629      var outputDirInput = document.getElementById("output_dir");
19630      var reportTitleInput = document.getElementById("report_title");
19631      var previewPanel = document.getElementById("preview-panel");
19632      var refreshButton = document.getElementById("refresh-preview");
19633      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19634      var useSamplePath = document.getElementById("use-sample-path");
19635      var useDefaultOutput = document.getElementById("use-default-output");
19636      var browsePath = document.getElementById("browse-path");
19637      var browseOutputDir = document.getElementById("browse-output-dir");
19638      var browseCoverage = document.getElementById("browse-coverage");
19639      var coverageInput = document.getElementById("coverage_file");
19640      var covScanStatus = document.getElementById("cov-scan-status");
19641      var coverageSuggestTimer = null;
19642      var covAutoFilled = false;
19643      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19644
19645      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19646      (function() {
19647        var ids = ["path", "output_dir"];
19648        ids.forEach(function(id) {
19649          var el = document.getElementById(id);
19650          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19651        });
19652      }());
19653      function fmtBytes(b) {
19654        b = Number(b) || 0;
19655        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19656        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19657        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
19658        return b + ' B';
19659      }
19660      var themeToggle = document.getElementById("theme-toggle");
19661
19662      function showBannerToast(msg, isError, opts) {
19663        opts = opts || {};
19664        var t = document.createElement('div');
19665        t.className = isError ? 'toast-error' : 'toast-success';
19666        var topPos = opts.top ? '80px' : null;
19667        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19668          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19669          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19670          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19671        if (opts.icon) {
19672          var inner = document.createElement('span');
19673          inner.innerHTML = opts.icon + ' ';
19674          t.appendChild(inner);
19675        }
19676        t.appendChild(document.createTextNode(msg));
19677        document.body.appendChild(t);
19678        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19679      }
19680      var mixedLinePolicy = document.getElementById("mixed_line_policy");
19681      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19682      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19683      var scanPreset = document.getElementById("scan_preset");
19684      var artifactPreset = document.getElementById("artifact_preset");
19685      var includeGlobsInput = document.getElementById("include_globs");
19686      var excludeGlobsInput = document.getElementById("exclude_globs");
19687
19688      // Include globs scope badge — updates reactively as the user types.
19689      (function() {
19690        var badge = document.getElementById("include-scope-badge");
19691        if (!badge || !includeGlobsInput) return;
19692        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> ';
19693        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> ';
19694        function update() {
19695          var val = includeGlobsInput.value.trim();
19696          if (!val) {
19697            badge.className = "include-scope-badge scope-all";
19698            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19699          } else {
19700            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19701            badge.className = "include-scope-badge scope-narrow";
19702            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19703          }
19704        }
19705        includeGlobsInput.addEventListener("input", update);
19706        update();
19707      }());
19708
19709      // Quick-exclude chips — append pattern to exclude_globs textarea.
19710      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19711        chip.addEventListener("click", function() {
19712          var pattern = chip.getAttribute("data-pattern") || "";
19713          if (!pattern || !excludeGlobsInput) return;
19714          var current = excludeGlobsInput.value.trim();
19715          // For the "skip all" chip, replace any existing dep patterns cleanly.
19716          var patterns = pattern.split("\n");
19717          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19718          var added = false;
19719          patterns.forEach(function(p) {
19720            p = p.trim();
19721            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19722          });
19723          if (added) {
19724            excludeGlobsInput.value = lines.join("\n");
19725            excludeGlobsInput.dispatchEvent(new Event("input"));
19726          }
19727          chip.classList.add("active");
19728        });
19729      });
19730
19731      var liveReportTitle = document.getElementById("live-report-title");
19732      var navProjectPill = document.getElementById("nav-project-pill");
19733      var navProjectTitle = document.getElementById("nav-project-title");
19734      var reportTitlePreview = null;
19735      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19736      var wizardProgressValue = document.getElementById("wizard-progress-value");
19737      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19738      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19739      var reportTitleTouched = false;
19740      var currentStep = 1;
19741      var previewTimer = null;
19742      var _previewGen = 0;
19743      // True while the scope preview (local) / project upload (server mode) is in
19744      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19745      // user can't advance past a project whose scope/upload isn't ready yet.
19746      var previewLoading = false;
19747      // Set when the current preview reports multiple independent git repos under
19748      // the selected root. Advancing past step 1 is blocked until the user ticks
19749      // the acknowledgement checkbox (or re-selects a single repository).
19750      var multiRepoBlocked = false;
19751      function step1ForwardBlocked() {
19752        return previewLoading || multiRepoBlocked;
19753      }
19754      function refreshStep1Gate() {
19755        var nextBtn = document.getElementById("step1-next");
19756        if (nextBtn) {
19757          var blocked = step1ForwardBlocked();
19758          nextBtn.classList.toggle("is-blocked", blocked);
19759          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19760        }
19761      }
19762      function setPreviewLoading(loading) {
19763        previewLoading = !!loading;
19764        var gate = document.getElementById("preview-gate-status");
19765        refreshStep1Gate();
19766        if (gate) {
19767          var txt = gate.querySelector(".preview-gate-text");
19768          if (txt) txt.textContent = SERVER_MODE
19769            ? "Uploading & scanning project…"
19770            : "Scanning project scope…";
19771          gate.style.display = previewLoading ? "flex" : "none";
19772        }
19773      }
19774      // Info button on the gate: scroll up to the live scope preview so the user
19775      // can see exactly what is being scanned (elapsed time + rotating status).
19776      var previewGateInfo = document.getElementById("preview-gate-info");
19777      if (previewGateInfo) {
19778        previewGateInfo.addEventListener("click", function () {
19779          var target = document.getElementById("preview-panel");
19780          if (!target) return;
19781          target.scrollIntoView({ behavior: "smooth", block: "center" });
19782          target.classList.add("preview-panel-flash");
19783          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19784        });
19785      }
19786      var quickScanBtn = document.getElementById("quick-scan-btn");
19787
19788      function dismissAnalysisModal() {
19789        if (loading) loading.classList.remove("active");
19790        document.body.classList.remove("modal-open");
19791        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19792          var el = document.getElementById(id);
19793          if (el) el.classList.add("hidden");
19794        });
19795        var cancelBtn = document.getElementById("lc-cancel-btn");
19796        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19797        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19798        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19799        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19800        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");}
19801        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19802        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19803        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19804        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19805        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19806        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19807      }
19808
19809      var lcDismissBtn = document.getElementById("lc-dismiss");
19810      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19811
19812      // When the browser restores this page from bfcache (Back button after navigating to results),
19813      // the loading overlay would still be showing its active state. Dismiss it immediately.
19814      window.addEventListener("pageshow", function(e) {
19815        if (e.persisted) { dismissAnalysisModal(); }
19816      });
19817
19818      function startAsyncAnalysis(formData) {
19819        var gitRepo = (formData.get("git_repo") || "").toString();
19820        var gitRef  = (formData.get("git_ref")  || "").toString();
19821        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19822        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19823
19824        var pathEl = document.getElementById("lc-path-text");
19825        if (pathEl) pathEl.textContent = displayPath;
19826
19827        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19828          var el = document.getElementById(id);
19829          if (el) el.classList.add("hidden");
19830        });
19831        var cancelBtn = document.getElementById("lc-cancel-btn");
19832        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19833        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19834        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19835        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19836        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19837        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19838        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19839        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");}
19840        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19841
19842        if (loading) loading.classList.add("active");
19843        document.body.classList.add("modal-open");
19844
19845        var startTime = Date.now();
19846        var elapsedTimer = setInterval(function() {
19847          var s = Math.floor((Date.now() - startTime) / 1000);
19848          var el = document.getElementById("lc-elapsed");
19849          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19850        }, 1000);
19851
19852        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19853
19854        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();}
19855
19856        var PHASE_DESC = {
19857          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19858          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19859          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19860          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19861          'Done': 'Analysis complete \u2014 loading your results\u2026',
19862          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19863        };
19864        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19865        function lcSetPhase(txt) {
19866          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19867          var desc = document.getElementById("lc-stage-desc");
19868          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19869          var step = PHASE_STEP[txt] || 1;
19870          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");}
19871        }
19872
19873        function lcShowCancelled() {
19874          clearInterval(elapsedTimer);
19875          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19876          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19877          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19878          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19879          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19880          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19881          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19882          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19883          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19884          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19885        }
19886
19887        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19888        if (lcCancelBtn) {
19889          lcCancelBtn.onclick = function() {
19890            if (!activeWaitId) { dismissAnalysisModal(); return; }
19891            lcCancelBtn.disabled = true;
19892            lcCancelBtn.textContent = "Cancelling\u2026";
19893            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19894              .then(function() { lcShowCancelled(); })
19895              .catch(function() { lcShowCancelled(); });
19896          };
19897        }
19898
19899        function lcShowError(msg) {
19900          clearInterval(elapsedTimer);
19901          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19902          lcSetPhase("Failed");
19903          var msgEl = document.getElementById("lc-err-msg");
19904          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19905          var errEl = document.getElementById("lc-err");
19906          var actEl = document.getElementById("lc-actions");
19907          if (errEl) errEl.classList.remove("hidden");
19908          if (actEl) actEl.classList.remove("hidden");
19909          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19910          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19911        }
19912
19913        function lcPoll(waitId) {
19914          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19915            .then(function(r) {
19916              if (!r.ok) throw new Error("HTTP " + r.status);
19917              return r.json();
19918            })
19919            .then(function(data) {
19920              pollRetries = 0;
19921              if (data.state === "complete") {
19922                clearInterval(elapsedTimer);
19923                lcSetPhase("Done");
19924                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19925              } else if (data.state === "failed") {
19926                lcShowError(data.message);
19927              } else if (data.state === "cancelled") {
19928                lcShowCancelled();
19929              } else {
19930                var s = Math.floor((Date.now() - startTime) / 1000);
19931                if (s > 90 && !warnShown) {
19932                  warnShown = true;
19933                  var w = document.getElementById("lc-warn");
19934                  if (w) w.classList.remove("hidden");
19935                }
19936                lcSetPhase(data.phase || "Running");
19937                var fd = data.files_done || 0, ft = data.files_total || 0;
19938                if (ft > 0) {
19939                  var card = document.getElementById("lc-files-card");
19940                  if (card) card.classList.remove("hidden");
19941                  var el = document.getElementById("lc-files");
19942                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19943                  var now = Date.now();
19944                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19945                  if (fdelta > 0 && tdelta > 0.4) {
19946                    var fps = Math.round(fdelta / tdelta);
19947                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19948                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19949                  }
19950                  lastFd = fd; lastFdTime = now;
19951                }
19952                setTimeout(function() { lcPoll(waitId); }, 1500);
19953              }
19954            })
19955            .catch(function() {
19956              pollRetries++;
19957              if (pollRetries >= 5) {
19958                lcShowError("Lost connection to server. Reload to check status.");
19959              } else {
19960                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19961              }
19962            });
19963        }
19964
19965        var params = new URLSearchParams(formData);
19966        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19967          .then(function(r) {
19968            var waitId = r.headers.get("x-wait-id");
19969            if (!waitId) { window.location.href = "/scan"; return; }
19970            activeWaitId = waitId;
19971            setTimeout(function() { lcPoll(waitId); }, 1500);
19972          })
19973          .catch(function(err) {
19974            lcShowError("Could not reach server: " + (err.message || err));
19975          });
19976      }
19977
19978      if (quickScanBtn) {
19979        quickScanBtn.addEventListener("click", function () {
19980          var pathVal = pathInput ? pathInput.value.trim() : "";
19981          if (!pathVal) {
19982            alert("Please enter or browse to a project path first.");
19983            return;
19984          }
19985          quickScanBtn.disabled = true;
19986          quickScanBtn.textContent = "Scanning...";
19987          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19988          startAsyncAnalysis(new FormData(form));
19989        });
19990      }
19991
19992      var mixedPolicyInfo = {
19993        code_only: {
19994          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.",
19995          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'
19996        },
19997        code_and_comment: {
19998          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.",
19999          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'
20000        },
20001        comment_only: {
20002          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.",
20003          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'
20004        },
20005        separate_mixed_category: {
20006          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.",
20007          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'
20008        }
20009      };
20010
20011      var scanPresetInfo = {
20012        balanced: {
20013          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.",
20014          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20015          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20016          note: "Best when you want a stable local overview before making deeper adjustments.",
20017          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20018        },
20019        code_focused: {
20020          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20021          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20022          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20023          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20024          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20025        },
20026        comment_audit: {
20027          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20028          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20029          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20030          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20031          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20032        },
20033        deep_review: {
20034          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20035          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20036          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20037          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20038          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20039        }
20040      };
20041
20042      var artifactPresetInfo = {
20043        review: {
20044          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20045          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20046          example: "Ideal for a quick local review before sharing results."
20047        },
20048        full: {
20049          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20050          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20051          example: "Use when producing a deliverable or storing a snapshot for future comparison."
20052        },
20053        html_only: {
20054          description: "Standalone HTML report only. No PDF generation, no data files.",
20055          chips: ["HTML only"],
20056          example: "Fastest option when you only need to open the report in a browser."
20057        },
20058        machine: {
20059          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20060          chips: ["JSON", "CSV", "no HTML", "no PDF"],
20061          example: "Use in CI to capture metrics without generating visual reports."
20062        }
20063      };
20064
20065      function applyArtifactPreset() {
20066        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20067        if (!info) return;
20068        var descEl = document.getElementById("artifact-preset-description");
20069        var exampleEl = document.getElementById("artifact-preset-example");
20070        if (descEl) descEl.textContent = info.description;
20071        if (exampleEl) exampleEl.textContent = info.example;
20072        renderPresetChips("artifact-preset-summary", info.chips);
20073      }
20074
20075      function applyTheme(theme) {
20076        if (theme === "dark") document.body.classList.add("dark-theme");
20077        else document.body.classList.remove("dark-theme");
20078      }
20079
20080      function loadSavedTheme() {
20081        var saved = null;
20082        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20083        applyTheme(saved === "dark" ? "dark" : "light");
20084      }
20085
20086      function updateScrollProgress() {
20087        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20088        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20089        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
20090        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20091        var step = Math.min(Math.max(currentStep, 1), 4);
20092        var base = stepBase[step];
20093        var end  = stepEnd[step];
20094
20095        var scrollFrac = 0;
20096        var activePanel = document.querySelector(".wizard-step.active");
20097        if (activePanel) {
20098          var scrollTop = window.scrollY || window.pageYOffset || 0;
20099          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20100          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20101          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20102          var scrolled = scrollTop + viewH - panelTop;
20103          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20104        }
20105
20106        var percent = Math.round(base + (end - base) * scrollFrac);
20107        percent = Math.min(end, Math.max(base, percent));
20108        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20109        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20110      }
20111
20112      function updateWizardProgress() {
20113        updateScrollProgress();
20114      }
20115
20116      var stepDescriptions = [
20117        "Choose a project folder, apply scope filters, and preview which files will be counted.",
20118        "Configure how mixed code-plus-comment lines and docstrings are classified.",
20119        "Pick your output formats, scan preset, and where reports are saved.",
20120        "Review all settings and launch the analysis."
20121      ];
20122
20123      function updateStepNav(step) {
20124        var infoLabel = document.getElementById("step-nav-info-label");
20125        var infoDesc  = document.getElementById("step-nav-info-desc");
20126        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20127        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
20128      }
20129
20130      function updateSidebarSummary() {
20131        var sumPath    = document.getElementById("sum-path");
20132        var sumPreset  = document.getElementById("sum-preset");
20133        var sumOutput  = document.getElementById("sum-output");
20134        var sidebarSummary = document.getElementById("sidebar-summary");
20135        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20136        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
20137        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20138        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
20139        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20140        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20141        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20142      }
20143
20144      function setStep(step, pushHistory) {
20145        currentStep = step;
20146        stepPanels.forEach(function (panel) {
20147          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20148        });
20149        stepButtons.forEach(function (button) {
20150          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20151        });
20152        var layoutEl = document.querySelector(".layout");
20153        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20154        updateWizardProgress();
20155        updateStepNav(step);
20156        stepButtons.forEach(function(btn) {
20157          var t = Number(btn.getAttribute("data-step-target"));
20158          btn.classList.toggle("done", t < step);
20159        });
20160        updateSidebarSummary();
20161
20162        if (pushHistory !== false) {
20163          try {
20164            history.pushState({ wizardStep: step }, "", "#step" + step);
20165          } catch (e) {}
20166        }
20167
20168        window.scrollTo({ top: 0, behavior: "instant" });
20169      }
20170
20171      window.addEventListener("popstate", function (e) {
20172        if (e.state && e.state.wizardStep) {
20173          setStep(e.state.wizardStep, false);
20174        } else {
20175          var hashMatch = location.hash.match(/^#step([1-4])$/);
20176          if (hashMatch) setStep(Number(hashMatch[1]), false);
20177        }
20178      });
20179
20180      function inferTitleFromPath(value) {
20181        if (!value) return "project";
20182        var cleaned = value.replace(/[\/\\]+$/, "");
20183        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20184        return parts.length ? parts[parts.length - 1] : value;
20185      }
20186
20187      function updateReportTitleFromPath() {
20188        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20189        if (!reportTitleTouched) {
20190          reportTitleInput.value = inferred;
20191        }
20192        var title = reportTitleInput.value || inferred;
20193        if (liveReportTitle) liveReportTitle.textContent = title;
20194        if (reportTitlePreview) reportTitlePreview.textContent = title;
20195        document.title = "OxideSLOC | " + title;
20196
20197        var projectPath = (pathInput.value || "").trim();
20198        if (navProjectPill && navProjectTitle) {
20199          if (projectPath.length > 0) {
20200            navProjectTitle.textContent = inferred;
20201            navProjectPill.classList.add("visible");
20202          } else {
20203            navProjectTitle.textContent = "";
20204            navProjectPill.classList.remove("visible");
20205          }
20206        }
20207      }
20208
20209      function updateMixedPolicyUI() {
20210        var key = mixedLinePolicy.value || "code_only";
20211        var info = mixedPolicyInfo[key];
20212        document.getElementById("mixed-policy-description").textContent = info.description;
20213        document.getElementById("mixed-policy-example").textContent = info.example;
20214      }
20215
20216      function updatePythonDocstringUI() {
20217        var checked = !!pythonDocstrings.checked;
20218        document.getElementById("python-docstring-example").textContent = checked
20219          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
20220          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
20221        document.getElementById("python-docstring-live-help").textContent = checked
20222          ? "Enabled: docstrings contribute to comment-style totals."
20223          : "Disabled: docstrings are not counted as comment content.";
20224      }
20225
20226      function renderPresetChips(targetId, chips) {
20227        var target = document.getElementById(targetId);
20228        if (!target) return;
20229        target.innerHTML = (chips || []).map(function (chip) {
20230          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20231        }).join('');
20232      }
20233
20234      function updatePresetDescriptions() {
20235        var scanInfo = scanPresetInfo[scanPreset.value];
20236        if (!scanInfo) return;
20237        document.getElementById("scan-preset-description").textContent = scanInfo.description;
20238        document.getElementById("scan-preset-example").textContent = scanInfo.example;
20239        document.getElementById("scan-preset-note").textContent = scanInfo.note;
20240        renderPresetChips("scan-preset-summary", scanInfo.chips);
20241      }
20242
20243      function applyScanPreset() {
20244        var info = scanPresetInfo[scanPreset.value];
20245        if (!info || !info.apply) return;
20246        mixedLinePolicy.value = info.apply.mixed;
20247        pythonDocstrings.checked = !!info.apply.docstrings;
20248        document.getElementById("generated_file_detection").value = info.apply.generated;
20249        document.getElementById("minified_file_detection").value = info.apply.minified;
20250        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20251        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20252        document.getElementById("binary_file_behavior").value = info.apply.binary;
20253        updateMixedPolicyUI();
20254        updatePythonDocstringUI();
20255      }
20256
20257      function updateReview() {
20258        var scanSummary = document.getElementById("review-scan-summary");
20259        var countSummary = document.getElementById("review-count-summary");
20260        var artifactSummary = document.getElementById("review-artifact-summary");
20261        var outputSummary = document.getElementById("review-output-summary");
20262        var previewSummary = document.getElementById("review-preview-summary");
20263        var readinessSummary = document.getElementById("review-readiness-summary");
20264        var includeText = document.getElementById("include_globs").value.trim();
20265        var excludeText = document.getElementById("exclude_globs").value.trim();
20266        var sidePathPreview = document.getElementById("side-path-preview");
20267        var sideOutputPreview = document.getElementById("side-output-preview");
20268        var sideTitlePreview = document.getElementById("side-title-preview");
20269
20270        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20271        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20272        if (sideTitlePreview) {
20273          var rt = document.getElementById("report_title");
20274          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20275        }
20276
20277        scanSummary.innerHTML = ""
20278          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20279          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20280          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20281
20282        countSummary.innerHTML = ""
20283          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20284          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20285          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20286          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20287          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20288          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20289          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20290          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20291
20292        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20293
20294        outputSummary.innerHTML = ""
20295          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20296          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20297
20298        if (previewSummary) {
20299          if (GIT_MODE) {
20300            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>';
20301          } else {
20302          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20303          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20304          var statMap = {};
20305          statButtons.forEach(function (button) {
20306            var valueNode = button.querySelector('.scope-stat-value');
20307            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20308          });
20309          previewSummary.innerHTML = ''
20310            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20311            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20312            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20313            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20314            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20315            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20316
20317          if (readinessSummary) {
20318            readinessSummary.innerHTML = ''
20319              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20320              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20321              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20322          }
20323          } // end else (non-GIT_MODE)
20324        }
20325      }
20326
20327      function escapeHtml(value) {
20328        return String(value)
20329          .replace(/&/g, "&amp;")
20330          .replace(/</g, "&lt;")
20331          .replace(/>/g, "&gt;")
20332          .replace(/"/g, "&quot;")
20333          .replace(/'/g, "&#39;");
20334      }
20335
20336      function isPythonVisible() {
20337        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20338      }
20339
20340      function syncPythonVisibility() {
20341        var html = previewPanel.textContent || "";
20342        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20343        pythonWraps.forEach(function (node) {
20344          node.classList.toggle("hidden", !hasPython);
20345        });
20346      }
20347
20348      function attachPreviewInteractions() {
20349        // Multiple-repository caution banner: gate step 1 until acknowledged, and
20350        // let each listed repo be picked as the scan root with one click.
20351        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20352        if (multiRepoBanner) {
20353          multiRepoBlocked = true;
20354          refreshStep1Gate();
20355          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20356          if (ackBox) {
20357            ackBox.addEventListener("change", function () {
20358              multiRepoBlocked = !ackBox.checked;
20359              refreshStep1Gate();
20360            });
20361          }
20362          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20363          repoButtons.forEach(function (btn) {
20364            btn.addEventListener("click", function () {
20365              var repoPath = btn.getAttribute("data-repo-path") || "";
20366              if (!repoPath || !pathInput) return;
20367              pathInput.value = repoPath;
20368              scrollInputToEnd(pathInput);
20369              updateReportTitleFromPath();
20370              autoSetOutputDir(repoPath);
20371              fetchProjectHistory(repoPath);
20372              loadPreview();
20373              updateReview();
20374            });
20375          });
20376        }
20377        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20378        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20379        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20380        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20381        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20382        var searchInput = previewPanel.querySelector("#explorer-search");
20383        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20384        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20385        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20386        var activeFilter = "all";
20387        var activeLanguage = "";
20388        var searchTerm = "";
20389        var currentSortKey = null;
20390        var currentSortOrder = "asc";
20391        var childRows = {};
20392
20393        rows.forEach(function (row) {
20394          var parentId = row.getAttribute("data-parent-id") || "";
20395          var rowId = row.getAttribute("data-row-id") || "";
20396          if (!childRows[parentId]) childRows[parentId] = [];
20397          childRows[parentId].push(rowId);
20398        });
20399
20400        function rowById(id) {
20401          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20402        }
20403
20404        function hasCollapsedAncestor(row) {
20405          var parentId = row.getAttribute("data-parent-id");
20406          while (parentId) {
20407            var parent = rowById(parentId);
20408            if (!parent) break;
20409            if (parent.getAttribute("data-expanded") === "false") return true;
20410            parentId = parent.getAttribute("data-parent-id");
20411          }
20412          return false;
20413        }
20414
20415        function updateToggleGlyph(row) {
20416          var toggle = row.querySelector(".tree-toggle");
20417          if (!toggle) return;
20418          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20419        }
20420
20421        function rowSortValue(row, key) {
20422          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20423        }
20424
20425        function updateSortButtons() {
20426          sortButtons.forEach(function (button) {
20427            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20428            var indicator = button.querySelector(".tree-sort-indicator");
20429            button.classList.toggle("active", isActive);
20430            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20431            if (indicator) {
20432              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20433            }
20434          });
20435        }
20436
20437        function sortSiblingRows() {
20438          if (!treeContainer) {
20439            updateSortButtons();
20440            return;
20441          }
20442
20443          var rowMap = {};
20444          var childrenMap = {};
20445          rows.forEach(function (row) {
20446            var rowId = row.getAttribute("data-row-id");
20447            var parentId = row.getAttribute("data-parent-id") || "";
20448            rowMap[rowId] = row;
20449            if (!childrenMap[parentId]) childrenMap[parentId] = [];
20450            childrenMap[parentId].push(rowId);
20451          });
20452
20453          Object.keys(childrenMap).forEach(function (parentId) {
20454            if (!parentId) return;
20455            childrenMap[parentId].sort(function (a, b) {
20456              var rowA = rowMap[a];
20457              var rowB = rowMap[b];
20458              if (!currentSortKey) {
20459                return Number(a) - Number(b);
20460              }
20461              var valueA = rowSortValue(rowA, currentSortKey);
20462              var valueB = rowSortValue(rowB, currentSortKey);
20463              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20464              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20465              var fallbackA = rowSortValue(rowA, "name");
20466              var fallbackB = rowSortValue(rowB, "name");
20467              if (fallbackA < fallbackB) return -1;
20468              if (fallbackA > fallbackB) return 1;
20469              return Number(a) - Number(b);
20470            });
20471          });
20472
20473          var orderedIds = [];
20474          function pushChildren(parentId) {
20475            (childrenMap[parentId] || []).forEach(function (childId) {
20476              orderedIds.push(childId);
20477              pushChildren(childId);
20478            });
20479          }
20480
20481          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20482            orderedIds.push(topId);
20483            pushChildren(topId);
20484          });
20485
20486          orderedIds.forEach(function (id) {
20487            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20488          });
20489          updateSortButtons();
20490        }
20491
20492        function updateLanguageButtons() {
20493          languageButtons.forEach(function (button) {
20494            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20495            var isActive = languageValue === activeLanguage;
20496            button.classList.toggle("active", isActive);
20497          });
20498        }
20499
20500        function rowSelfMatches(row) {
20501          var kind = row.getAttribute("data-kind");
20502          var status = row.getAttribute("data-status");
20503          var language = (row.getAttribute("data-language") || "").toLowerCase();
20504          var name = row.getAttribute("data-name-lower") || "";
20505          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20506          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20507          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20508          var passesLanguage = !activeLanguage || language === activeLanguage;
20509          return passesFilter && passesSearch && passesLanguage;
20510        }
20511
20512        function hasMatchingDescendant(rowId) {
20513          return (childRows[rowId] || []).some(function (childId) {
20514            var childRow = rowById(childId);
20515            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20516          });
20517        }
20518
20519        function rowMatches(row) {
20520          if (rowSelfMatches(row)) return true;
20521          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20522        }
20523
20524        function resetViewState() {
20525          activeFilter = "all";
20526          activeLanguage = "";
20527          searchTerm = "";
20528          currentSortKey = null;
20529          currentSortOrder = "asc";
20530          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20531          if (searchInput) searchInput.value = "";
20532          if (filterSelect) filterSelect.value = "all";
20533          updateLanguageButtons();
20534        }
20535
20536        function applyVisibility() {
20537          rows.forEach(function (row) {
20538            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20539            row.classList.toggle("hidden-by-filter", !visible);
20540            row.style.display = visible ? "grid" : "none";
20541          });
20542          buttons.forEach(function (button) {
20543            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20544          });
20545          if (filterSelect) filterSelect.value = activeFilter;
20546        }
20547
20548        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20549        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20550        var originalStats = {};
20551        buttons.forEach(function (btn) {
20552          var f = btn.getAttribute('data-filter');
20553          var v = btn.querySelector('.scope-stat-value');
20554          if (f && v) originalStats[f] = v.textContent;
20555        });
20556
20557        function applySubmoduleStats(statsJson) {
20558          try {
20559            var s = JSON.parse(statsJson);
20560            buttons.forEach(function (btn) {
20561              var f = btn.getAttribute('data-filter');
20562              var v = btn.querySelector('.scope-stat-value');
20563              if (!v) return;
20564              if (f === 'dir') v.textContent = s.dirs;
20565              else if (f === 'file') v.textContent = s.files;
20566              else if (f === 'supported') v.textContent = s.supported;
20567              else if (f === 'skipped') v.textContent = s.skipped;
20568              else if (f === 'unsupported') v.textContent = s.unsupported;
20569            });
20570          } catch (e) {}
20571        }
20572
20573        function restoreBaseRepoStats() {
20574          buttons.forEach(function (btn) {
20575            var f = btn.getAttribute('data-filter');
20576            var v = btn.querySelector('.scope-stat-value');
20577            if (v && originalStats[f]) v.textContent = originalStats[f];
20578          });
20579          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20580          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20581        }
20582
20583        submoduleChips.forEach(function (chip) {
20584          chip.addEventListener('click', function () {
20585            var statsJson = chip.getAttribute('data-sub-stats');
20586            if (!statsJson) return;
20587            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20588            chip.classList.add('active');
20589            applySubmoduleStats(statsJson);
20590            if (baseRepoBtn) baseRepoBtn.style.display = '';
20591          });
20592        });
20593
20594        if (baseRepoBtn) {
20595          baseRepoBtn.addEventListener('click', function () {
20596            restoreBaseRepoStats();
20597            resetViewState();
20598            sortSiblingRows();
20599            applyVisibility();
20600          });
20601        }
20602
20603        buttons.forEach(function (button) {
20604          button.addEventListener("click", function () {
20605            var filterValue = button.getAttribute("data-filter") || "all";
20606            if (filterValue === "reset-view") {
20607              restoreBaseRepoStats();
20608              resetViewState();
20609              sortSiblingRows();
20610              applyVisibility();
20611              return;
20612            }
20613            activeFilter = filterValue;
20614            applyVisibility();
20615          });
20616        });
20617
20618        rows.forEach(function (row) {
20619          updateToggleGlyph(row);
20620          var toggle = row.querySelector(".tree-toggle");
20621          if (toggle) {
20622            toggle.addEventListener("click", function () {
20623              var expanded = row.getAttribute("data-expanded") !== "false";
20624              row.setAttribute("data-expanded", expanded ? "false" : "true");
20625              updateToggleGlyph(row);
20626              applyVisibility();
20627            });
20628          }
20629        });
20630
20631        actionButtons.forEach(function (button) {
20632          button.addEventListener("click", function () {
20633            var action = button.getAttribute("data-explorer-action");
20634            if (action === "expand-all") {
20635              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20636            } else if (action === "collapse-all") {
20637              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20638            } else if (action === "clear-filters") {
20639              resetViewState();
20640            }
20641            sortSiblingRows();
20642            applyVisibility();
20643          });
20644        });
20645
20646        if (filterSelect) {
20647          filterSelect.addEventListener("change", function () {
20648            activeFilter = filterSelect.value || "all";
20649            applyVisibility();
20650          });
20651        }
20652
20653        languageButtons.forEach(function (button) {
20654          button.addEventListener("click", function () {
20655            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20656            updateLanguageButtons();
20657            applyVisibility();
20658          });
20659        });
20660
20661        sortButtons.forEach(function (button) {
20662          button.addEventListener("click", function () {
20663            var sortKey = button.getAttribute("data-sort-key");
20664            if (currentSortKey === sortKey) {
20665              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20666            } else {
20667              currentSortKey = sortKey;
20668              currentSortOrder = "asc";
20669            }
20670            sortSiblingRows();
20671            applyVisibility();
20672          });
20673        });
20674
20675        if (searchInput) {
20676          searchInput.addEventListener("input", function () {
20677            searchTerm = searchInput.value.trim().toLowerCase();
20678            applyVisibility();
20679          });
20680        }
20681
20682        updateLanguageButtons();
20683        sortSiblingRows();
20684        applyVisibility();
20685      }
20686
20687      function loadPreview() {
20688        if (!previewPanel || !pathInput) return;
20689        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20690        multiRepoBlocked = false;
20691        refreshStep1Gate();
20692        if (GIT_MODE) {
20693          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>';
20694          setPreviewLoading(false);
20695          return;
20696        }
20697        var path = pathInput.value.trim();
20698        var zeroWarn = document.getElementById('zero-files-warning');
20699        if (!path) {
20700          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20701          if (zeroWarn) zeroWarn.style.display = 'none';
20702          setPreviewLoading(false);
20703          return;
20704        }
20705        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20706        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20707        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20708        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20709        var myGen = ++_previewGen;
20710        var _prevMsgs = [
20711          'Scanning directory structure\u2026',
20712          'Detecting file types\u2026',
20713          'Applying include / exclude filters\u2026',
20714          'Estimating file counts\u2026',
20715          'Building scope preview\u2026',
20716          'Almost there\u2026'
20717        ];
20718        var _prevMsgIdx = 0;
20719        var _prevStart = Date.now();
20720        previewPanel.innerHTML =
20721          '<div class="preview-loading">' +
20722          '<div class="preview-spinner"></div>' +
20723          '<div class="preview-loading-text">' +
20724          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20725          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20726          '</div></div>';
20727        var _sizeTextEl = document.getElementById('project-size-text');
20728        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20729        window._previewInterval = setInterval(function() {
20730          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20731          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20732          var ml = document.getElementById('plm');
20733          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20734        }, 1500);
20735        window._previewElapsedTimer = setInterval(function() {
20736          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20737          var el = document.getElementById('ple');
20738          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20739        }, 1000);
20740        setPreviewLoading(true);
20741        var previewUrl = "/preview?path=" + encodeURIComponent(path)
20742          + "&include_globs=" + encodeURIComponent(includeValue)
20743          + "&exclude_globs=" + encodeURIComponent(excludeValue);
20744        fetch(previewUrl)
20745          .then(function (response) { return response.text(); })
20746          .then(function (html) {
20747            if (myGen !== _previewGen) return;
20748            clearInterval(window._previewInterval); window._previewInterval = null;
20749            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20750            setPreviewLoading(false);
20751            previewPanel.innerHTML = html;
20752            attachPreviewInteractions();
20753            syncPythonVisibility();
20754            updateReview();
20755            setTimeout(collapseLanguagePills, 50);
20756            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20757            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20758            var sizeText = document.getElementById('project-size-text');
20759            var sizeBtn = document.getElementById('project-size-btn');
20760            // In server mode with upload sizes available, keep the compressed/original pair.
20761            if (SERVER_MODE && window._lastUploadSizes) {
20762              var us = window._lastUploadSizes;
20763              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20764                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20765              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20766                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20767            } else if (sizeText && projectSize) {
20768              sizeText.textContent = 'Project size: ' + projectSize;
20769              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20770            } else if (sizeText) {
20771              sizeText.textContent = 'Project size: \u2014';
20772            }
20773            if (zeroWarn) {
20774              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20775              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20776              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20777              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20778              if (supportedCount === 0 && fileCount > 0) {
20779                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).';
20780                zeroWarn.style.display = '';
20781              } else {
20782                zeroWarn.style.display = 'none';
20783              }
20784            }
20785          })
20786          .catch(function (err) {
20787            if (myGen !== _previewGen) return;
20788            clearInterval(window._previewInterval); window._previewInterval = null;
20789            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20790            setPreviewLoading(false);
20791            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20792          });
20793      }
20794
20795      function pickDirectory(targetInput, kind) {
20796        if (!targetInput) {
20797          showBannerToast("Directory picker: input element not found.", true);
20798          return;
20799        }
20800        if (SERVER_MODE) {
20801          if (kind === 'output') {
20802            showBannerToast(
20803              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20804              false,
20805              { top: true, icon: '\u{1F4C1}' }
20806            );
20807            return;
20808          }
20809          var inputEl = kind === 'coverage'
20810            ? document.getElementById('cov-upload-input')
20811            : document.getElementById('dir-upload-input');
20812          if (!inputEl) return;
20813          inputEl.onchange = function () {
20814            var files = inputEl.files;
20815            if (!files || files.length === 0) return;
20816            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20817            if (browseBtn) browseBtn.disabled = true;
20818
20819            function fileToBase64(file) {
20820              return new Promise(function (resolve, reject) {
20821                var reader = new FileReader();
20822                reader.onload = function () {
20823                  var b64 = reader.result.split(',')[1];
20824                  resolve(b64);
20825                };
20826                reader.onerror = reject;
20827                reader.readAsDataURL(file);
20828              });
20829            }
20830
20831            if (kind === 'coverage') {
20832              var f = files[0];
20833              if (previewPanel && targetInput === pathInput)
20834                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20835              fileToBase64(f).then(function (b64) {
20836                return fetch('/api/upload-file', {
20837                  method: 'POST',
20838                  headers: { 'Content-Type': 'application/json' },
20839                  body: JSON.stringify({ filename: f.name, content: b64 })
20840                }).then(function (r) { return r.json(); });
20841              })
20842                .then(function (d) {
20843                  if (d && d.tmp_path) {
20844                    if (coverageInput) coverageInput.value = d.tmp_path;
20845                    setCovStatus('idle');
20846                  } else if (d && d.error) { showBannerToast(d.error, true); }
20847                })
20848                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20849                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20850            } else {
20851              // ── Filter to source-code files only ─────────────────────────
20852              // Binary, generated, and dependency files (node_modules, .git,
20853              // build artifacts) are skipped so they are never uploaded.
20854              var CODE_EXTS = new Set([
20855                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20856                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20857                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20858                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20859                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20860                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20861                'tf','hcl','proto','thrift','avsc','graphql','gql'
20862              ]);
20863              var codeFiles = [];
20864              for (var i = 0; i < files.length; i++) {
20865                var f = files[i];
20866                var name = f.name;
20867                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20868                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20869                  codeFiles.push(f); continue;
20870                }
20871                var dot = name.lastIndexOf('.');
20872                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20873              }
20874              // Collect specific .git metadata files for server-side git detection.
20875              // These have no source extension so they are excluded by the loop above,
20876              // but the server needs them to read branch/commit/author without running git.
20877              var gitMetaFiles = [];
20878              for (var i = 0; i < files.length; i++) {
20879                var f = files[i];
20880                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20881                var gitIdx = rp.indexOf('/.git/');
20882                if (gitIdx < 0) continue;
20883                var gitRel = rp.slice(gitIdx + 1);
20884                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20885                    gitRel === '.git/logs/HEAD' ||
20886                    gitRel.startsWith('.git/refs/heads/') ||
20887                    gitRel.startsWith('.git/refs/tags/')) {
20888                  gitMetaFiles.push(f);
20889                }
20890              }
20891              var uploadFiles = codeFiles.concat(gitMetaFiles);
20892              var total = files.length;
20893              var kept = codeFiles.length;
20894              if (kept === 0) {
20895                if (previewPanel && targetInput === pathInput)
20896                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20897                if (browseBtn) browseBtn.disabled = false;
20898                inputEl.value = '';
20899                return;
20900              }
20901
20902              // ── Helper: apply upload result to UI ────────────────────────
20903              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20904              function applyUploadResult(tmpPath, sizes) {
20905                targetInput.value = tmpPath;
20906                scrollInputToEnd(targetInput);
20907                if (sizes && SERVER_MODE) {
20908                  window._lastUploadSizes = sizes;
20909                  // Immediately show both sizes before preview loads.
20910                  var sizeText = document.getElementById('project-size-text');
20911                  var sizeBtn = document.getElementById('project-size-btn');
20912                  if (sizeText) {
20913                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20914                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20915                  }
20916                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20917                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20918                }
20919                if (targetInput === pathInput) {
20920                  updateReportTitleFromPath();
20921                  autoSetOutputDir(tmpPath);
20922                  fetchProjectHistory(tmpPath);
20923                  loadPreview();
20924                  suggestCoverageFile(tmpPath);
20925                }
20926                updateReview();
20927                if (browseBtn) browseBtn.disabled = false;
20928                inputEl.value = '';
20929              }
20930
20931              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20932              if (typeof CompressionStream !== 'undefined') {
20933                if (previewPanel && targetInput === pathInput)
20934                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20935
20936                // Build a minimal POSIX ustar tar header for a single file entry.
20937                function buildUstarHeader(filePath, fileSize) {
20938                  var BLOCK = 512;
20939                  var hdr = new Uint8Array(BLOCK);
20940                  var enc = new TextEncoder();
20941                  function wStr(off, len, s) {
20942                    var b = enc.encode(s);
20943                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20944                  }
20945                  function wOct(off, len, val) {
20946                    var s = val.toString(8);
20947                    while (s.length < len - 1) s = '0' + s;
20948                    wStr(off, len, s + '\0');
20949                  }
20950                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20951                  var name = filePath, prefix = '';
20952                  if (filePath.length > 99) {
20953                    var split = filePath.lastIndexOf('/', 154);
20954                    if (split > 0 && filePath.length - split - 1 <= 99) {
20955                      prefix = filePath.substring(0, split);
20956                      name   = filePath.substring(split + 1);
20957                    } else { name = filePath.substring(0, 99); }
20958                  }
20959                  wStr(0,   100, name);          // name
20960                  wOct(100,   8, 0o000644);      // mode
20961                  wOct(108,   8, 0);             // uid
20962                  wOct(116,   8, 0);             // gid
20963                  wOct(124,  12, fileSize);      // size
20964                  wOct(136,  12, 0);             // mtime (epoch)
20965                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20966                  hdr[156] = 48;                 // type flag '0' = regular file
20967                  wStr(157, 100, '');            // linkname
20968                  wStr(257,   6, 'ustar');       // magic
20969                  wStr(263,   2, '00');          // version
20970                  wStr(265,  32, '');            // uname
20971                  wStr(297,  32, '');            // gname
20972                  wOct(329,   8, 0);             // devmajor
20973                  wOct(337,   8, 0);             // devminor
20974                  wStr(345, 155, prefix);        // prefix
20975                  // Compute checksum (sum of all bytes, placeholder = 32).
20976                  var chk = 0;
20977                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20978                  var cs = chk.toString(8);
20979                  while (cs.length < 6) cs = '0' + cs;
20980                  wStr(148, 8, cs + '\0 ');
20981                  return hdr;
20982                }
20983
20984                // Build tar.gz one file at a time, piping through CompressionStream.
20985                // RAM usage = compressed output buffer + one file at a time.
20986                (async function () {
20987                  try {
20988                    var BLOCK = 512;
20989                    var cs     = new CompressionStream('gzip');
20990                    var writer = cs.writable.getWriter();
20991                    var chunks = [];
20992                    var reader = cs.readable.getReader();
20993                    var collecting = (async function () {
20994                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20995                    })();
20996
20997                    for (var i = 0; i < uploadFiles.length; i++) {
20998                      var file = uploadFiles[i];
20999                      var path = file.webkitRelativePath || file.name;
21000                      var buf  = await file.arrayBuffer();
21001                      var data = new Uint8Array(buf);
21002                      // Header block
21003                      await writer.write(buildUstarHeader(path, data.length));
21004                      // Data padded to 512-byte boundary
21005                      if (data.length > 0) {
21006                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21007                        var block  = new Uint8Array(padded);
21008                        block.set(data);
21009                        await writer.write(block);
21010                      }
21011                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21012                        if (previewPanel && targetInput === pathInput)
21013                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21014                      }
21015                    }
21016                    // End-of-archive: two 512-byte zero blocks
21017                    await writer.write(new Uint8Array(BLOCK * 2));
21018                    await writer.close();
21019                    await collecting;
21020
21021                    var blob = new Blob(chunks, { type: 'application/gzip' });
21022                    var sizeMB = (blob.size / 1048576).toFixed(1);
21023                    if (previewPanel && targetInput === pathInput)
21024                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21025
21026                    var resp = await fetch('/api/upload-tarball', {
21027                      method: 'POST',
21028                      headers: { 'Content-Type': 'application/gzip' },
21029                      body: blob
21030                    });
21031                    var d = await resp.json();
21032                    if (d && d.tmp_path) {
21033                      applyUploadResult(d.tmp_path, {
21034                        compressed_bytes: d.compressed_bytes || 0,
21035                        original_bytes: d.original_bytes || 0
21036                      });
21037                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21038                  } catch (e) {
21039                    showBannerToast('Upload failed: ' + String(e), true);
21040                    if (browseBtn) browseBtn.disabled = false;
21041                    inputEl.value = '';
21042                  }
21043                })();
21044
21045              } else {
21046                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21047                // Used only on browsers that lack CompressionStream (pre-2023).
21048                var BATCH = 200;
21049                var batches = [];
21050                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21051                var totalBatches = batches.length;
21052                if (previewPanel && targetInput === pathInput)
21053                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21054
21055                function sendBatch(idx, currentUploadId, lastTmpPath) {
21056                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21057                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
21058                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21059                  Promise.all(batches[idx].map(function (file) {
21060                    return fileToBase64(file).then(function (b64) {
21061                      return { path: file.webkitRelativePath || file.name, content: b64 };
21062                    });
21063                  })).then(function (fileList) {
21064                    var body = { files: fileList };
21065                    if (currentUploadId) body.upload_id = currentUploadId;
21066                    return fetch('/api/upload-directory', {
21067                      method: 'POST', headers: { 'Content-Type': 'application/json' },
21068                      body: JSON.stringify(body)
21069                    }).then(function (r) { return r.json(); });
21070                  }).then(function (d) {
21071                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21072                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21073                  }).catch(function (e) {
21074                    showBannerToast('Upload failed: ' + String(e), true);
21075                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21076                  });
21077                }
21078                sendBatch(0, null, '');
21079              }
21080            }
21081          };
21082          inputEl.click();
21083          return;
21084        }
21085
21086        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21087        if (browseButton) browseButton.disabled = true;
21088
21089        if (previewPanel && targetInput === pathInput) {
21090          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21091        }
21092
21093        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
21094          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21095          .then(function (data) {
21096            if (data && data.selected_path) {
21097              targetInput.value = data.selected_path;
21098              scrollInputToEnd(targetInput);
21099
21100              if (targetInput === pathInput) {
21101                updateReportTitleFromPath();
21102                autoSetOutputDir(data.selected_path);
21103                fetchProjectHistory(data.selected_path);
21104                loadPreview();
21105                suggestCoverageFile(data.selected_path);
21106              }
21107
21108              updateReview();
21109            } else if (targetInput === pathInput) {
21110              loadPreview();
21111            }
21112          })
21113          .catch(function () {
21114            window.alert("Directory picker request failed.");
21115            if (previewPanel && targetInput === pathInput) {
21116              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21117            }
21118          })
21119          .finally(function () {
21120            if (browseButton) browseButton.disabled = false;
21121          });
21122      }
21123
21124      if (themeToggle) {
21125        themeToggle.addEventListener("click", function () {
21126          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21127          applyTheme(nextTheme);
21128          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21129        });
21130      }
21131
21132      stepButtons.forEach(function (button) {
21133        button.addEventListener("click", function () {
21134          var target = Number(button.getAttribute("data-step-target"));
21135          // Block jumping forward off step 1 while the preview / upload is running
21136          // or while a multi-repository selection is unacknowledged.
21137          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21138          setStep(target);
21139        });
21140      });
21141
21142      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21143        button.addEventListener("click", function () {
21144          var target = Number(button.getAttribute("data-step-target")) || 1;
21145          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21146          setStep(target);
21147        });
21148      });
21149
21150      // True when the project path is untouched from the bundled sample default.
21151      function isDefaultSamplePath() {
21152        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21153      }
21154
21155      var defaultPathOverlay = document.getElementById("default-path-overlay");
21156      function closeDefaultPathModal() {
21157        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21158      }
21159      function openDefaultPathModal() {
21160        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21161      }
21162
21163      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21164        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21165        // that borrow the .next-step style class but carry no data-next target).
21166        if (!button.hasAttribute("data-next")) return;
21167        button.addEventListener("click", function () {
21168          // Guard step 1 → 2: block while the scope preview / upload is still running
21169          // or while a multi-repository selection is unacknowledged.
21170          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21171          // Guard step 1 → 2: warn when the project path is still the sample default.
21172          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21173            openDefaultPathModal();
21174            return;
21175          }
21176          updateReview();
21177          setStep(Number(button.getAttribute("data-next")));
21178        });
21179      });
21180
21181      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21182        if (!button.hasAttribute("data-prev")) return;
21183        button.addEventListener("click", function () {
21184          setStep(Number(button.getAttribute("data-prev")));
21185        });
21186      });
21187
21188      // Default-sample-path confirmation modal wiring.
21189      var defaultPathProceed = document.getElementById("default-path-proceed");
21190      if (defaultPathProceed) {
21191        defaultPathProceed.addEventListener("click", function () {
21192          closeDefaultPathModal();
21193          updateReview();
21194          setStep(2);
21195        });
21196      }
21197      var defaultPathCancel = document.getElementById("default-path-cancel");
21198      if (defaultPathCancel) {
21199        defaultPathCancel.addEventListener("click", function () {
21200          closeDefaultPathModal();
21201          if (pathInput) { pathInput.focus(); pathInput.select(); }
21202        });
21203      }
21204      if (defaultPathOverlay) {
21205        defaultPathOverlay.addEventListener("click", function (e) {
21206          if (e.target === defaultPathOverlay) closeDefaultPathModal();
21207        });
21208      }
21209      document.addEventListener("keydown", function (e) {
21210        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21211          closeDefaultPathModal();
21212        }
21213      });
21214
21215      document.addEventListener("keydown", function (e) {
21216        var tag = (document.activeElement || {}).tagName || "";
21217        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21218        if (e.altKey || e.ctrlKey || e.metaKey) return;
21219        if (e.key === "ArrowRight" && currentStep < 4) {
21220          if (currentStep === 1 && step1ForwardBlocked()) return;
21221          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21222          updateReview(); setStep(currentStep + 1);
21223        }
21224        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21225      });
21226
21227      if (useSamplePath) {
21228        useSamplePath.addEventListener("click", function () {
21229          pathInput.value = "testing/fixtures/basic";
21230          updateReportTitleFromPath();
21231          autoSetOutputDir("testing/fixtures/basic");
21232          loadPreview();
21233          suggestCoverageFile("testing/fixtures/basic");
21234        });
21235      }
21236
21237      if (useDefaultOutput) {
21238        useDefaultOutput.addEventListener("click", function () {
21239          delete outputDirInput.dataset.userEdited;
21240          autoSetOutputDir(pathInput ? pathInput.value : "");
21241          updateReview();
21242        });
21243      }
21244
21245      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21246      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21247
21248      // ── Drag-and-drop directory upload (server mode only) ─────────────────
21249      // Dropping a folder onto the path field bypasses Chrome's
21250      // "Upload X files to this site?" confirmation dialog.
21251      async function readDirRecursively(dirEntry, basePath) {
21252        var reader = dirEntry.createReader();
21253        var all = [];
21254        for (;;) {
21255          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21256          if (!batch.length) break;
21257          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21258        }
21259        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21260        var out = [];
21261        for (var i = 0; i < all.length; i++) {
21262          var sub = all[i];
21263          if (sub.isFile) {
21264            var f = await new Promise(function(res) { sub.file(res); });
21265            out.push({ file: f, path: basePath + '/' + sub.name });
21266          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21267            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21268            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21269          }
21270        }
21271        return out;
21272      }
21273
21274      function setupPathDropZone() {
21275        if (!SERVER_MODE || !pathInput) return;
21276        var CODE_EXTS = new Set([
21277          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21278          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21279          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21280          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21281          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21282          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21283        ]);
21284        pathInput.addEventListener('dragover', function(e) {
21285          e.preventDefault();
21286          pathInput.classList.add('drag-over');
21287        });
21288        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21289        pathInput.addEventListener('drop', function(e) {
21290          e.preventDefault();
21291          pathInput.classList.remove('drag-over');
21292          var items = e.dataTransfer.items;
21293          if (!items || !items.length) return;
21294          var dirEntry = null;
21295          for (var i = 0; i < items.length; i++) {
21296            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21297            if (entry && entry.isDirectory) { dirEntry = entry; break; }
21298          }
21299          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21300          var btn = browsePath;
21301          if (btn) btn.disabled = true;
21302          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21303
21304          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21305            var total = allEntries.length;
21306            var codeEntries = allEntries.filter(function(e) {
21307              var n = e.file.name;
21308              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21309              var dot = n.lastIndexOf('.');
21310              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21311            });
21312            var kept = codeEntries.length;
21313            if (kept === 0) {
21314              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21315              if (btn) btn.disabled = false; return;
21316            }
21317
21318            function finish(tmpPath, sizes) {
21319              pathInput.value = tmpPath;
21320              scrollInputToEnd(pathInput);
21321              if (sizes) {
21322                window._lastUploadSizes = sizes;
21323                var sizeText = document.getElementById('project-size-text');
21324                var sizeBtn = document.getElementById('project-size-btn');
21325                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21326                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21327                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21328                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21329              }
21330              updateReportTitleFromPath();
21331              autoSetOutputDir(tmpPath);
21332              fetchProjectHistory(tmpPath);
21333              loadPreview();
21334              suggestCoverageFile(tmpPath);
21335              updateReview();
21336              if (btn) btn.disabled = false;
21337            }
21338
21339            if (typeof CompressionStream === 'undefined') {
21340              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21341              if (btn) btn.disabled = false; return;
21342            }
21343
21344            try {
21345              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21346              var BLOCK = 512;
21347              var cs = new CompressionStream('gzip');
21348              var wtr = cs.writable.getWriter();
21349              var chunks = [];
21350              var rdr = cs.readable.getReader();
21351              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21352
21353              function buildHdr(fp, sz) {
21354                var hdr = new Uint8Array(BLOCK);
21355                var enc = new TextEncoder();
21356                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]; }
21357                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21358                var nm = fp, pfx = '';
21359                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); } }
21360                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21361                for (var i = 148; i < 156; i++) hdr[i] = 32;
21362                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);
21363                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21364                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21365                return hdr;
21366              }
21367
21368              for (var i = 0; i < codeEntries.length; i++) {
21369                var ce = codeEntries[i];
21370                var buf = await ce.file.arrayBuffer();
21371                var data = new Uint8Array(buf);
21372                await wtr.write(buildHdr(ce.path, data.length));
21373                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21374                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21375                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21376              }
21377              await wtr.write(new Uint8Array(BLOCK * 2));
21378              await wtr.close();
21379              await collecting;
21380
21381              var blob = new Blob(chunks, { type: 'application/gzip' });
21382              var sizeMB = (blob.size / 1048576).toFixed(1);
21383              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21384              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21385              var d = await resp.json();
21386              if (d && d.tmp_path) {
21387                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21388              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21389            } catch (err) {
21390              showBannerToast('Upload failed: ' + String(err), true);
21391              if (btn) btn.disabled = false;
21392            }
21393          }).catch(function(err) {
21394            showBannerToast('Could not read folder: ' + String(err), true);
21395            if (btn) btn.disabled = false;
21396          });
21397        });
21398      }
21399      setupPathDropZone();
21400      if (browseCoverage) {
21401        browseCoverage.addEventListener("click", function () {
21402          pickDirectory(coverageInput || pathInput, "coverage");
21403        });
21404      }
21405
21406      function setCovStatus(state, opts) {
21407        if (!covScanStatus) return;
21408        opts = opts || {};
21409        covScanStatus.className = "cov-scan-status cov-scan-" + state;
21410        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21411        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>';
21412        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>';
21413        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>';
21414        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>';
21415        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21416        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21417        if (state === "scanning") {
21418          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21419        } else if (state === "found") {
21420          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21421          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21422          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21423          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21424        } else if (state === "hint") {
21425          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21426          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
21427          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>';
21428        } else if (state === "none") {
21429          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21430          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
21431        }
21432        html += '</div></div>';
21433        covScanStatus.innerHTML = html;
21434        if (state === "found") {
21435          var useBtn = covScanStatus.querySelector(".cov-scan-use");
21436          if (useBtn) useBtn.addEventListener("click", function () {
21437            if (coverageInput) coverageInput.value = "";
21438            covAutoFilled = false;
21439            setCovStatus("idle");
21440          });
21441        }
21442      }
21443
21444      function suggestCoverageFile(projectPath) {
21445        if (!coverageInput || !covScanStatus) return;
21446        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21447        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21448        clearTimeout(coverageSuggestTimer);
21449        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21450        setCovStatus("scanning");
21451        coverageSuggestTimer = setTimeout(function () {
21452          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21453            .then(function (r) { return r.json(); })
21454            .then(function (d) {
21455              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21456              if (!d) { setCovStatus("none"); return; }
21457              if (d.found) {
21458                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21459                setCovStatus("found", { found: d.found, tool: d.tool });
21460              } else if (d.tool && d.hint) {
21461                setCovStatus("hint", { tool: d.tool, hint: d.hint });
21462              } else {
21463                setCovStatus("none");
21464              }
21465            })
21466            .catch(function () { setCovStatus("idle"); });
21467        }, 600);
21468      }
21469
21470      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21471
21472      if (coverageInput) coverageInput.addEventListener("input", function () {
21473        covAutoFilled = false;
21474        if (!this.value.trim()) setCovStatus("idle");
21475      });
21476
21477      // ── Language pill overflow: collapse to "+N more" chip ─────────────
21478      function collapseLanguagePills() {
21479        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21480        rows.forEach(function(row) {
21481          // Remove any previous overflow chip
21482          var prev = row.querySelector('.lang-overflow-chip');
21483          if (prev) prev.remove();
21484          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21485          pills.forEach(function(p) { p.style.display = ''; });
21486          if (!pills.length) return;
21487
21488          // Measure after restoring all pills
21489          var containerRight = row.getBoundingClientRect().right;
21490          var hidden = [];
21491          for (var i = pills.length - 1; i >= 1; i--) {
21492            var rect = pills[i].getBoundingClientRect();
21493            if (rect.right > containerRight + 2) {
21494              hidden.unshift(pills[i]);
21495              pills[i].style.display = 'none';
21496            } else {
21497              break;
21498            }
21499          }
21500
21501          if (hidden.length) {
21502            var chip = document.createElement('button');
21503            chip.type = 'button';
21504            chip.className = 'language-pill lang-overflow-chip';
21505            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21506            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21507            row.appendChild(chip);
21508          }
21509        });
21510      }
21511
21512      // Run after preview loads (preview panel populates language pills)
21513      var _origLoadPreviewCb = window.__previewLoaded;
21514      document.addEventListener('previewLoaded', collapseLanguagePills);
21515      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21516      setTimeout(collapseLanguagePills, 400);
21517
21518      // ── Project history & output dir auto-set ──────────────────────────
21519      var wsOutputRoot   = document.getElementById("ws-output-root");
21520      var wsScanCount    = document.getElementById("ws-scan-count");
21521      var wsLastScan     = document.getElementById("ws-last-scan");
21522      var historyBadge   = document.getElementById("path-history-badge");
21523      var historyTimer   = null;
21524
21525      var wsOutputLink = document.getElementById("ws-output-link");
21526      function syncStripOutputRoot() {
21527        var val = outputDirInput ? outputDirInput.value : "";
21528        var display = val || "project/sloc";
21529        if (wsOutputRoot) wsOutputRoot.textContent = display;
21530        if (wsOutputLink) wsOutputLink.dataset.folder = val;
21531      }
21532
21533      function scrollInputToEnd(input) {
21534        if (!input) return;
21535        // Defer so the DOM has the new value before we measure scroll width.
21536        requestAnimationFrame(function () {
21537          input.scrollLeft = input.scrollWidth;
21538          input.selectionStart = input.selectionEnd = input.value.length;
21539        });
21540      }
21541
21542      function autoSetOutputDir(projectPath) {
21543        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21544        if (GIT_MODE && GIT_OUTPUT_DIR) {
21545          outputDirInput.value = GIT_OUTPUT_DIR;
21546          scrollInputToEnd(outputDirInput);
21547          syncStripOutputRoot();
21548          updateReview();
21549          return;
21550        }
21551        if (!projectPath || !projectPath.trim()) return;
21552        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21553        outputDirInput.value = cleaned + "/sloc";
21554        scrollInputToEnd(outputDirInput);
21555        syncStripOutputRoot();
21556        updateReview();
21557      }
21558
21559      var wsBranch = document.getElementById("ws-branch");
21560
21561      function fetchProjectHistory(projectPath) {
21562        if (!projectPath || !projectPath.trim()) {
21563          if (wsScanCount) wsScanCount.textContent = "\u2014";
21564          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
21565          if (wsBranch)    wsBranch.textContent    = "\u2014";
21566          if (historyBadge) historyBadge.style.display = "none";
21567          return;
21568        }
21569        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21570          .then(function (r) { return r.ok ? r.json() : null; })
21571          .then(function (data) {
21572            if (!data) return;
21573            var countStr = data.scan_count > 0
21574              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21575              : "never";
21576            var tsStr = data.last_scan_timestamp
21577              ? data.last_scan_timestamp.replace(" UTC","")
21578              : "\u2014";
21579            if (wsScanCount) wsScanCount.textContent = countStr;
21580            if (wsLastScan)  wsLastScan.textContent  = tsStr;
21581            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
21582            if (data.scan_count > 0) {
21583              if (historyBadge) {
21584                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21585                historyBadge.textContent = data.scan_count + " previous scan" +
21586                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21587                  "Last: " + (data.last_scan_timestamp || "\u2014") +
21588                  " \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.";
21589                historyBadge.className = "path-history-badge found";
21590                historyBadge.style.display = "";
21591              }
21592            } else {
21593              if (historyBadge) historyBadge.style.display = "none";
21594            }
21595          })
21596          .catch(function () {});
21597      }
21598
21599      function onPathChange() {
21600        var val = pathInput ? pathInput.value : "";
21601        // Discard stale upload sizes when the user edits the path manually.
21602        window._lastUploadSizes = null;
21603        updateReportTitleFromPath();
21604        autoSetOutputDir(val);
21605        updateSidebarSummary();
21606        clearTimeout(historyTimer);
21607        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21608        if (previewTimer) clearTimeout(previewTimer);
21609        previewTimer = setTimeout(loadPreview, 280);
21610        suggestCoverageFile(val);
21611      }
21612
21613      if (pathInput) {
21614        pathInput.addEventListener("input", onPathChange);
21615      }
21616
21617      if (outputDirInput) {
21618        outputDirInput.addEventListener("input", function () {
21619          outputDirInput.dataset.userEdited = "1";
21620          syncStripOutputRoot();
21621          updateReview();
21622        });
21623      }
21624
21625      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21626        if (!node) return;
21627        node.addEventListener("input", function () {
21628          updateReview();
21629          if (previewTimer) clearTimeout(previewTimer);
21630          previewTimer = setTimeout(loadPreview, 280);
21631        });
21632      });
21633
21634      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21635        var node = document.getElementById(id);
21636        if (node) node.addEventListener("change", updateReview);
21637      });
21638
21639      if (reportTitleInput) {
21640        reportTitleInput.addEventListener("input", function () {
21641          reportTitleTouched = reportTitleInput.value.trim().length > 0;
21642          updateReportTitleFromPath();
21643          updateReview();
21644        });
21645      }
21646
21647      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21648      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21649      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21650      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21651
21652      if (coverageInput) {
21653        coverageInput.addEventListener("input", function () {
21654          if (coverageInput.value.trim()) setCovStatus("idle");
21655        });
21656      }
21657
21658      if (form && loading && submitButton) {
21659        form.addEventListener("submit", function (e) {
21660          e.preventDefault();
21661          submitButton.disabled = true;
21662          submitButton.textContent = "Scanning...";
21663          startAsyncAnalysis(new FormData(form));
21664        });
21665      }
21666
21667      function openPath(folder) {
21668        if (!folder) return;
21669        fetch('/open-path?path=' + encodeURIComponent(folder))
21670          .then(function (r) { return r.json(); })
21671          .then(function (d) {
21672            if (d && d.server_mode_disabled)
21673              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21674          })
21675          .catch(function () {});
21676      }
21677
21678      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21679        btn.addEventListener('click', function () {
21680          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21681        });
21682      });
21683
21684      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21685      if (wsOutputLink) {
21686        wsOutputLink.addEventListener('click', function () {
21687          openPath(wsOutputLink.dataset.folder || '');
21688        });
21689      }
21690
21691      loadSavedTheme();
21692      updateMixedPolicyUI();
21693      updatePythonDocstringUI();
21694      applyScanPreset();
21695      updatePresetDescriptions();
21696      applyArtifactPreset();
21697      updateReview();
21698      updateScrollProgress(); // initialise bar to 0% (step 1)
21699      window.addEventListener("scroll", updateScrollProgress, { passive: true });
21700      onPathChange();         // seed output dir, history badge, and preview from initial path
21701      updateStepNav(1);
21702
21703      // Restore step from URL hash on initial load (e.g., back-forward cache)
21704      (function() {
21705        var hashMatch = location.hash.match(/^#step([1-4])$/);
21706        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21707      })();
21708
21709      (function randomizeWatermarks() {
21710        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21711        if (!wms.length) return;
21712        var placed = [];
21713        function tooClose(top, left) {
21714          for (var i = 0; i < placed.length; i++) {
21715            var dt = Math.abs(placed[i][0] - top);
21716            var dl = Math.abs(placed[i][1] - left);
21717            if (dt < 16 && dl < 12) return true;
21718          }
21719          return false;
21720        }
21721        function pick(leftBand) {
21722          for (var attempt = 0; attempt < 50; attempt++) {
21723            var top = Math.random() * 88 + 2;
21724            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21725            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21726          }
21727          var top = Math.random() * 88 + 2;
21728          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21729          placed.push([top, left]);
21730          return [top, left];
21731        }
21732        var half = Math.floor(wms.length / 2);
21733        wms.forEach(function (img, i) {
21734          var pos = pick(i < half);
21735          var size = Math.floor(Math.random() * 80 + 110);
21736          var rot = (Math.random() * 360).toFixed(1);
21737          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21738          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;
21739        });
21740      })();
21741
21742      (function spawnCodeParticles() {
21743        var container = document.getElementById('code-particles');
21744        if (!container) return;
21745        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'];
21746        for (var i = 0; i < 38; i++) {
21747          (function(idx) {
21748            var el = document.createElement('span');
21749            el.className = 'code-particle';
21750            el.textContent = snippets[idx % snippets.length];
21751            var left = Math.random() * 94 + 2;
21752            var top = Math.random() * 88 + 6;
21753            var dur = (Math.random() * 10 + 9).toFixed(1);
21754            var delay = (Math.random() * 18).toFixed(1);
21755            var rot = (Math.random() * 26 - 13).toFixed(1);
21756            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21757            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';
21758            container.appendChild(el);
21759          })(i);
21760        }
21761      })();
21762    })();
21763  </script>
21764  <script nonce="{{ csp_nonce }}">
21765    (function () {
21766      var raw = {{ prefill_json|safe }};
21767      if (!raw || typeof raw !== 'object' || !raw.path) return;
21768      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21769      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21770      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21771      setVal('path', raw.path || '');
21772      setVal('include_globs', raw.include_globs || '');
21773      setVal('exclude_globs', raw.exclude_globs || '');
21774      setVal('output_dir', raw.output_dir || '');
21775      setVal('report_title', raw.report_title || '');
21776      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21777      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21778      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21779      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21780      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21781      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21782      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21783      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21784      setChecked('generate_html', raw.generate_html !== false);
21785      setChecked('generate_pdf', !!raw.generate_pdf);
21786      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21787      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21788      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21789      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21790      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21791      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21792      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21793      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21794      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21795      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21796      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21797      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21798      // Trigger dynamic UI updates after pre-fill.
21799      setTimeout(function () {
21800        var pathEl = document.getElementById('path');
21801        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21802        var policyEl = document.getElementById('mixed_line_policy');
21803        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21804      }, 80);
21805    })();
21806  </script>
21807  <script nonce="{{ csp_nonce }}">
21808  (function(){
21809    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'}];
21810    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);});}
21811    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21812    function init(){
21813      var btn=document.getElementById('settings-btn');if(!btn)return;
21814      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21815      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>';
21816      document.body.appendChild(m);
21817      var g=document.getElementById('scheme-grid');
21818      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);});
21819      var cl=document.getElementById('settings-close');
21820      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);});})();
21821      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');});
21822      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21823      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21824    }
21825    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21826  }());
21827  </script>
21828  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21829    <div class="wb-ftip-arrow"></div>
21830    <span id="wb-ftip-text"></span>
21831  </div>
21832  <script nonce="{{ csp_nonce }}">(function(){
21833    var tip=document.getElementById('wb-ftip');
21834    var txt=document.getElementById('wb-ftip-text');
21835    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21836    if(!tip||!txt)return;
21837    function pos(el){
21838      var r=el.getBoundingClientRect();
21839      tip.style.display='block';
21840      var tw=tip.offsetWidth;
21841      var lx=r.left+r.width/2-tw/2;
21842      if(lx<8)lx=8;
21843      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21844      tip.style.left=lx+'px';
21845      tip.style.top=(r.bottom+8)+'px';
21846      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';}
21847    }
21848    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21849      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21850      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21851    });
21852    window.addEventListener('blur',function(){tip.style.display='none';});
21853    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21854  })();
21855  (function(){
21856    function fixArtifactHintSpacing(){
21857      var grid=document.querySelector('.artifact-grid');
21858      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21859    }
21860    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21861  }());
21862  (function(){
21863    var dot=document.getElementById('status-dot');
21864    var pingEl=document.getElementById('server-ping-ms');
21865    var tipEl=document.getElementById('server-tip-ping');
21866    var fm=document.getElementById('footer-mode');
21867    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)';}}
21868    function doPing(){
21869      var t0=performance.now();
21870      fetch('/healthz',{cache:'no-store'})
21871        .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);})
21872        .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)';}});
21873    }
21874    doPing();
21875    setInterval(doPing,5000);
21876    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');}
21877  })();
21878  </script>
21879  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21880  <footer class="site-footer">
21881    local code analysis - metrics, history and reports
21882    &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>
21883    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21884    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21885    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21886    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21887  </footer>
21888</body>
21889</html>
21890"##,
21891    ext = "html"
21892)]
21893struct IndexTemplate {
21894    version: &'static str,
21895    prefill_json: String,
21896    csp_nonce: String,
21897    git_repo: String,
21898    git_ref: String,
21899    git_label_json: String,
21900    git_output_dir_json: String,
21901    server_mode: bool,
21902}
21903
21904// ── SplashTemplate ────────────────────────────────────────────────────────────
21905
21906#[derive(Template)]
21907#[template(
21908    source = r##"
21909<!doctype html>
21910<html lang="en">
21911<head>
21912  <meta charset="utf-8">
21913  <meta name="viewport" content="width=device-width, initial-scale=1">
21914  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21915  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21916  <script type="application/ld+json">
21917  {
21918    "@context": "https://schema.org",
21919    "@type": "SoftwareApplication",
21920    "name": "oxide-sloc",
21921    "applicationCategory": "DeveloperApplication",
21922    "operatingSystem": "Windows, Linux",
21923    "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.",
21924    "softwareVersion": "{{ version }}",
21925    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21926    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21927    "url": "https://github.com/oxide-sloc/oxide-sloc",
21928    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21929    "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",
21930    "programmingLanguage": "Rust",
21931    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21932  }
21933  </script>
21934  <style nonce="{{ csp_nonce }}">
21935    :root {
21936      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21937      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21938      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21939      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21940      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21941    }
21942    body.dark-theme {
21943      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21944      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21945    }
21946    *{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;}
21947    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21948    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21949    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21950    .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;}
21951    @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));}}
21952    .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);}
21953    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21954    .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));}
21955    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21956    .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;}
21957    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21958    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21959    @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; } }
21960    .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;}
21961    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21962    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21963    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21964    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21965    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21966    .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;}
21967    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21968    .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);}
21969    .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;}
21970    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21971    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21972    .settings-modal-body{padding:14px 16px 16px;}
21973    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21974    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21975    .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;}
21976    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21977    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21978    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21979    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21980    .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;}
21981    .tz-select:focus{border-color:var(--oxide);}
21982    .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;}
21983    .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;}
21984    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21985    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21986    .hero{text-align:center;margin:0 auto 18px;}
21987    .hero-logo-wrap{display:inline-block;cursor:default;}
21988    .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;}
21989    .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;}
21990    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21991    .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;}
21992    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%);}
21993    .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;
21994      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21995      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21996      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;}
21997    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21998    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21999    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;}
22000    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
22001    .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;}
22002    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22003    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22004    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22005    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22006    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22007    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22008    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22009    .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;}
22010    .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;}
22011    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22012    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22013    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22014    .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);}
22015    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22016    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22017    .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);}
22018    .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);}
22019    .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);}
22020    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22021    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22022    .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;}
22023    body.dark-theme .action-card-cta{color:var(--oxide);}
22024    .action-card.view .action-card-cta{color:var(--accent-2);}
22025    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22026    .action-card.compare .action-card-cta{color:#7c3aed;}
22027    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22028    .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);}
22029    .action-card.git-tools .action-card-cta{color:#15803d;}
22030    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22031    .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);}
22032    .action-card.trend .action-card-cta{color:#0e7490;}
22033    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22034    .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);}
22035    .action-card.automation .action-card-cta{color:#b45309;}
22036    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22037    .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);}
22038    .action-card.test-metrics .action-card-cta{color:#be185d;}
22039    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22040    .action-card:hover .action-card-cta{gap:12px;}
22041    .action-card.card-split{flex-direction:row;align-items:stretch;}
22042    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22043    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22044    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22045    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22046    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22047    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22048    .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;}
22049    .ac-badge.active{opacity:1;}
22050    .ac-badge.github{border-color:#555;color:#555;}
22051    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22052    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22053    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22054    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22055    body.dark-theme .ac-right-row{color:var(--muted);}
22056    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22057    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22058    .divider{height:1px;background:var(--line);margin:32px 0;}
22059    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22060    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22061    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22062    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22063      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22064    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22065    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22066    body.dark-theme .info-chip-val{color:var(--oxide);}
22067    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22068    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22069      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22070      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22071    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22072      border:6px solid transparent;border-top-color:var(--text);}
22073    .info-chip:hover .info-chip-tip{display:block;}
22074    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22075    .chip-slide.fading{filter:blur(5px);opacity:0;}
22076    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22077    .site-footer a{color:var(--muted);}
22078    .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;}
22079    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22080    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22081    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22082    .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;}
22083    .lan-badge.local{background:var(--oxide-2);}
22084    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22085    .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);}
22086    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22087    .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;}
22088    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22089    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22090    .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;}
22091    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22092    .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;}
22093    .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);}
22094    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22095    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22096    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22097    .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;}
22098    @media (max-height: 1100px) {
22099      .page{padding-top:10px;}
22100      .hero{margin-bottom:10px;}
22101      .hero-logo{width:54px;height:60px;}
22102      .hero-logo-shadow{width:42px;}
22103      .hero-title{font-size:28px;}
22104      .hero-subtitle{font-size:13px;}
22105      .card-sections{gap:12px;margin-bottom:6px;}
22106      .card-section-grid-2,.card-section-grid-3{gap:10px;}
22107      .action-card{padding:8px 15px 8px;}
22108      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22109      .action-card-icon svg{width:18px;height:18px;}
22110      .action-card-title{font-size:13px;}
22111      .action-card-desc{font-size:11px;margin-bottom:6px;}
22112      .action-card-cta{font-size:11px;}
22113      .ac-right-row{font-size:11px;}
22114      .divider{margin:14px 0;}
22115      .info-strip{gap:7px;margin-bottom:8px;}
22116      .info-chip{padding:7px 10px;}
22117      .info-chip-val{font-size:13px;}
22118      .info-chip-label{font-size:9px;}
22119      .site-footer{padding:8px 24px;font-size:12px;}
22120      .lan-local-hint{margin-top:8px;}
22121    }
22122    @media (max-height: 850px) {
22123      .page{padding-top:6px;}
22124      .hero{margin-bottom:6px;}
22125      .hero-logo{width:42px;height:46px;}
22126      .hero-title{font-size:22px;}
22127      .hero-subtitle{font-size:12px;}
22128      .card-sections{gap:10px;}
22129      .action-card-desc{margin-bottom:4px;}
22130      .divider{margin:8px 0;}
22131      .info-strip{margin-bottom:6px;}
22132      .lan-local-hint{margin-top:10px;}
22133    }
22134  </style>
22135</head>
22136<body>
22137  <div class="background-watermarks" aria-hidden="true">
22138    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22139    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22140    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22141    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22142    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22143    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22144    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22145  </div>
22146  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22147  <div class="top-nav">
22148    <div class="top-nav-inner">
22149      <a class="brand" href="/">
22150        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22151        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22152      </a>
22153      <div class="nav-right">
22154        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22155        <div class="nav-dropdown">
22156          <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>
22157          <div class="nav-dropdown-menu">
22158            <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>
22159          </div>
22160        </div>
22161        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22162        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22163        <div class="nav-dropdown">
22164          <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>
22165          <div class="nav-dropdown-menu">
22166            <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>
22167          </div>
22168        </div>
22169        <div class="server-status-wrap" id="server-status-wrap">
22170          <div class="nav-pill server-online-pill" id="server-status-pill">
22171            <span class="status-dot" id="status-dot"></span>
22172            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22173            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22174          </div>
22175          <div class="server-status-tip">
22176            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22177            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22178          </div>
22179        </div>
22180        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22181          <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>
22182        </button>
22183        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22184          <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>
22185          <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>
22186        </button>
22187      </div>
22188    </div>
22189  </div>
22190
22191  <div class="page">
22192    <div class="hero">
22193      <div class="hero-logo-wrap" id="hero-logo-wrap">
22194        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22195      </div>
22196      <div class="hero-logo-shadow"></div>
22197      <div class="hero-title-wrap">
22198        <div class="hero-title-aura" aria-hidden="true"></div>
22199        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22200      </div>
22201      <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>
22202    </div>
22203
22204    <div class="card-sections">
22205
22206      <div>
22207        <div class="card-section-label">Analysis</div>
22208        <div class="card-section-grid-2">
22209          <a class="action-card scan card-split" href="/scan-setup">
22210            <div class="action-card-left">
22211              <div class="action-card-icon">
22212                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22213              </div>
22214              <div class="action-card-title">Scan Project</div>
22215              <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>
22216              <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>
22217            </div>
22218            <div class="action-card-sep"></div>
22219            <div class="action-card-right">
22220              <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>
22221              <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>
22222              <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>
22223              <div class="ac-right-stat" id="acp-scan-stat"></div>
22224            </div>
22225          </a>
22226          <a class="action-card test-metrics card-split" href="/test-metrics">
22227            <div class="action-card-left">
22228              <div class="action-card-icon">
22229                <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>
22230              </div>
22231              <div class="action-card-title">Test Metrics</div>
22232              <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>
22233              <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>
22234            </div>
22235            <div class="action-card-sep"></div>
22236            <div class="action-card-right">
22237              <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>
22238              <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>
22239              <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>
22240              <div class="ac-right-stat" id="acp-test-stat"></div>
22241            </div>
22242          </a>
22243        </div>
22244      </div>
22245
22246      <div>
22247        <div class="card-section-label">Reports &amp; Insights</div>
22248        <div class="card-section-grid-3">
22249          <a class="action-card view" href="/view-reports">
22250            <div class="action-card-icon">
22251              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22252            </div>
22253            <div class="action-card-title">View Reports</div>
22254            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22255            <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>
22256          </a>
22257          <a class="action-card compare" href="/compare-scans">
22258            <div class="action-card-icon">
22259              <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>
22260            </div>
22261            <div class="action-card-title">Compare Scans</div>
22262            <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>
22263            <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>
22264          </a>
22265          <a class="action-card trend" href="/trend-reports">
22266            <div class="action-card-icon">
22267              <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>
22268            </div>
22269            <div class="action-card-title">Trend Report</div>
22270            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22271            <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>
22272          </a>
22273        </div>
22274      </div>
22275
22276      <div>
22277        <div class="card-section-label">Developer Tools</div>
22278        <div class="card-section-grid-2">
22279          <a class="action-card git-tools card-split" href="/git-browser">
22280            <div class="action-card-left">
22281              <div class="action-card-icon">
22282                <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>
22283              </div>
22284              <div class="action-card-title">Git Browser</div>
22285              <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>
22286              <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>
22287            </div>
22288            <div class="action-card-sep"></div>
22289            <div class="action-card-right">
22290              <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>
22291              <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>
22292              <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>
22293            </div>
22294          </a>
22295          <a class="action-card automation card-split" href="/integrations">
22296            <div class="action-card-left">
22297              <div class="action-card-icon">
22298                <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>
22299              </div>
22300              <div class="action-card-title">Integrations</div>
22301              <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>
22302              <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>
22303            </div>
22304            <div class="action-card-sep"></div>
22305            <div class="action-card-right">
22306              <div class="ac-badges-grid">
22307                <span class="ac-badge github"     id="acp-gh">GitHub</span>
22308                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
22309                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
22310                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22311              </div>
22312              <div class="ac-right-stat" id="acp-int-stat"></div>
22313            </div>
22314          </a>
22315        </div>
22316      </div>
22317
22318    </div>
22319
22320    {% if server_mode %}
22321    <div class="lan-card server">
22322      <div class="lan-card-header">
22323        <span class="lan-badge">LAN server</span>
22324        Accessible on your network
22325      </div>
22326      {% if let Some(ip) = lan_ip %}
22327      <div class="lan-url-row">
22328        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22329        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22330          <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>
22331          Copy URL
22332        </button>
22333      </div>
22334      <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>
22335      {% if has_api_key %}
22336      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
22337      {% endif %}
22338      {% else %}
22339      <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>
22340      {% endif %}
22341    </div>
22342    {% endif %}
22343
22344    <div class="divider"></div>
22345
22346    <div class="info-strip">
22347      <div class="info-chip">
22348        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22349        <div class="chip-slide">
22350          <div class="info-chip-val">60</div>
22351          <div class="info-chip-label">Languages</div>
22352        </div>
22353      </div>
22354      <div class="info-chip">
22355        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22356        <div class="chip-slide">
22357          <div class="info-chip-val">100%</div>
22358          <div class="info-chip-label">Self-contained</div>
22359        </div>
22360      </div>
22361      <div class="info-chip">
22362        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22363        <div class="chip-slide">
22364          <div class="info-chip-val">HTML+PDF</div>
22365          <div class="info-chip-label">Exportable reports</div>
22366        </div>
22367      </div>
22368      <div class="info-chip">
22369        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22370        <div class="chip-slide">
22371          <div class="info-chip-val">Webhook</div>
22372          <div class="info-chip-label">3 platforms</div>
22373        </div>
22374      </div>
22375      <div class="info-chip">
22376        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22377        <div class="chip-slide">
22378          <div class="info-chip-val">IEEE</div>
22379          <div class="info-chip-label">1045-1992</div>
22380        </div>
22381      </div>
22382    </div>
22383
22384    {% if lan_ip.is_none() %}
22385    <div class="lan-local-hint">
22386      <strong>Want teammates on the same network to access this?</strong><br>
22387      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
22388    </div>
22389    {% endif %}
22390  </div>
22391
22392  <footer class="site-footer">
22393    local code analysis - metrics, history and reports
22394    &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>
22395    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22396    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22397    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22398    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22399  </footer>
22400
22401  <script nonce="{{ csp_nonce }}">
22402    (function () {
22403      var storageKey = 'oxide-sloc-theme';
22404      var body = document.body;
22405      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22406      var toggle = document.getElementById('theme-toggle');
22407      if (toggle) toggle.addEventListener('click', function () {
22408        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22409        body.classList.toggle('dark-theme', next === 'dark');
22410        try { localStorage.setItem(storageKey, next); } catch(e) {}
22411      });
22412      var copyBtn = document.getElementById('lan-copy-btn');
22413      if (copyBtn) copyBtn.addEventListener('click', function() {
22414        var btn = this;
22415        var el = document.getElementById('lan-url-val');
22416        if (!el) return;
22417        var url = el.textContent.trim();
22418        if (navigator.clipboard) {
22419          navigator.clipboard.writeText(url).then(function() {
22420            var orig = btn.innerHTML;
22421            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!';
22422            setTimeout(function() { btn.innerHTML = orig; }, 1800);
22423          });
22424        }
22425      });
22426      (function randomizeWatermarks() {
22427        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22428        if (!wms.length) return;
22429        var placed = [];
22430        function tooClose(top, left) {
22431          for (var i = 0; i < placed.length; i++) {
22432            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22433            if (dt < 16 && dl < 12) return true;
22434          }
22435          return false;
22436        }
22437        function pick(leftBand) {
22438          for (var attempt = 0; attempt < 50; attempt++) {
22439            var top = Math.random() * 88 + 2;
22440            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22441            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22442          }
22443          var top = Math.random() * 88 + 2;
22444          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22445          placed.push([top, left]); return [top, left];
22446        }
22447        var half = Math.floor(wms.length / 2);
22448        wms.forEach(function (img, i) {
22449          var pos = pick(i < half);
22450          var size = Math.floor(Math.random() * 100 + 120);
22451          var rot = (Math.random() * 360).toFixed(1);
22452          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22453          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;
22454        });
22455      })();
22456
22457      (function spawnCodeParticles() {
22458        var container = document.getElementById('code-particles');
22459        if (!container) return;
22460        var snippets = [
22461          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22462          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22463          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22464          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22465          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22466        ];
22467        var count = 38;
22468        for (var i = 0; i < count; i++) {
22469          (function(idx) {
22470            var el = document.createElement('span');
22471            el.className = 'code-particle';
22472            var text = snippets[idx % snippets.length];
22473            el.textContent = text;
22474            var left = Math.random() * 94 + 2;
22475            var top = Math.random() * 88 + 6;
22476            var dur = (Math.random() * 10 + 9).toFixed(1);
22477            var delay = (Math.random() * 18).toFixed(1);
22478            var rot = (Math.random() * 26 - 13).toFixed(1);
22479            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22480            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22481              + '--rot:' + rot + 'deg;--op:' + op + ';'
22482              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22483            container.appendChild(el);
22484          })(i);
22485        }
22486      })();
22487      (function heroAnimations() {
22488        var sub = document.getElementById('hero-subtitle');
22489        if (sub) {
22490          var full = sub.textContent.trim();
22491          sub.textContent = '';
22492          sub.style.opacity = '1';
22493          var cursor = document.createElement('span');
22494          cursor.className = 'hero-cursor';
22495          sub.appendChild(cursor);
22496          var i = 0;
22497          setTimeout(function() {
22498            var iv = setInterval(function() {
22499              if (i < full.length) {
22500                sub.insertBefore(document.createTextNode(full[i]), cursor);
22501                i++;
22502              } else {
22503                clearInterval(iv);
22504                setTimeout(function() {
22505                  cursor.style.transition = 'opacity 1s ease';
22506                  cursor.style.opacity = '0';
22507                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22508                }, 2400);
22509              }
22510            }, 11);
22511          }, 374);
22512        }
22513      })();
22514      (function logoBob() {
22515        var logo = document.querySelector('.hero-logo');
22516        var shadow = document.querySelector('.hero-logo-shadow');
22517        if (!logo) return;
22518        var cycleStart = null, cycleDur = 3600;
22519        var peakY = -14, peakScale = 1.07, peakRot = 0;
22520        function newCycle() {
22521          cycleDur = 3000 + Math.random() * 1840;
22522          peakY = -(9 + Math.random() * 13.8);
22523          peakScale = 1.04 + Math.random() * 0.081;
22524          peakRot = (Math.random() * 11.5 - 5.75);
22525        }
22526        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22527        newCycle();
22528        function frame(ts) {
22529          if (cycleStart === null) cycleStart = ts;
22530          var t = (ts - cycleStart) / cycleDur;
22531          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22532          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22533          var y = peakY * phase;
22534          var sc = 1 + (peakScale - 1) * phase;
22535          var rot = peakRot * Math.sin(Math.PI * phase);
22536          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22537          if (shadow) {
22538            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22539            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22540          }
22541          requestAnimationFrame(frame);
22542        }
22543        requestAnimationFrame(frame);
22544      })();
22545      (function mouseEffects() {
22546        var heroTitle = document.getElementById('hero-title');
22547        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22548        function tick() {
22549          raf = null;
22550          if (heroTitle) {
22551            var r = heroTitle.getBoundingClientRect();
22552            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22553            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22554            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22555          }
22556        }
22557        document.addEventListener('mousemove', function(e) {
22558          mx = e.clientX; my = e.clientY;
22559          if (!raf) raf = requestAnimationFrame(tick);
22560        });
22561        document.addEventListener('mouseleave', function() {
22562          if (heroTitle) {
22563            heroTitle.style.transition = 'transform 0.5s ease';
22564            heroTitle.style.transform = '';
22565            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22566          }
22567        });
22568        document.querySelectorAll('.action-card').forEach(function(card) {
22569          card.addEventListener('mousemove', function(e) {
22570            var rect = card.getBoundingClientRect();
22571            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22572            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22573            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22574            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22575          });
22576          card.addEventListener('mouseleave', function() {
22577            card.style.transition = '';
22578            card.style.transform = '';
22579          });
22580        });
22581      })();
22582      (function chipSlideshow() {
22583        var slides = [
22584          [{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'}],
22585          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22586          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22587          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22588          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22589        ];
22590        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22591        var indices = [0,0,0,0,0];
22592        var paused = [false,false,false,false,false];
22593        chips.forEach(function(chip, i) {
22594          chip.addEventListener('mouseenter', function() { paused[i] = true; });
22595          chip.addEventListener('mouseleave', function() { paused[i] = false; });
22596        });
22597        function advance(i) {
22598          if (paused[i]) return;
22599          var chip = chips[i];
22600          var inner = chip.querySelector('.chip-slide');
22601          if (!inner) return;
22602          inner.classList.add('fading');
22603          setTimeout(function() {
22604            indices[i] = (indices[i] + 1) % slides[i].length;
22605            var s = slides[i][indices[i]];
22606            chip.querySelector('.info-chip-val').textContent = s.v;
22607            chip.querySelector('.info-chip-label').textContent = s.l;
22608            inner.classList.remove('fading');
22609          }, 720);
22610        }
22611        setInterval(function() {
22612          chips.forEach(function(chip, i) { advance(i); });
22613        }, 6000);
22614      })();
22615      (function cardLiveData() {
22616        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22617          var el = document.getElementById('acp-scan-stat');
22618          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22619        }).catch(function(){});
22620        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22621          var el = document.getElementById('acp-test-stat');
22622          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22623        }).catch(function(){});
22624        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22625          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22626          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22627          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22628          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22629          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22630          var stat = document.getElementById('acp-int-stat');
22631          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22632        }).catch(function(){});
22633        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22634          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22635        }).catch(function(){});
22636      })();
22637    })();
22638  </script>
22639  <script nonce="{{ csp_nonce }}">
22640  (function(){
22641    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'}];
22642    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);});}
22643    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22644    function init(){
22645      var btn=document.getElementById('settings-btn');if(!btn)return;
22646      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22647      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>';
22648      document.body.appendChild(m);
22649      var g=document.getElementById('scheme-grid');
22650      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);});
22651      var cl=document.getElementById('settings-close');
22652      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);});})();
22653      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');});
22654      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22655      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22656    }
22657    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22658  }());
22659  </script>
22660  <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>
22661</body>
22662</html>
22663"##,
22664    ext = "html"
22665)]
22666struct SplashTemplate {
22667    csp_nonce: String,
22668    server_mode: bool,
22669    lan_ip: Option<String>,
22670    port: u16,
22671    version: &'static str,
22672    has_api_key: bool,
22673}
22674
22675// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
22676
22677#[derive(Template)]
22678#[template(
22679    source = r##"
22680<!doctype html>
22681<html lang="en">
22682<head>
22683  <meta charset="utf-8">
22684  <meta name="viewport" content="width=device-width, initial-scale=1">
22685  <title>OxideSLOC — Start a Scan</title>
22686  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22687  <style nonce="{{ csp_nonce }}">
22688    :root {
22689      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22690      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22691      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22692      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22693      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22694    }
22695    body.dark-theme {
22696      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22697      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22698    }
22699    *{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;}
22700    .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);}
22701    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22702    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22703    .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));}
22704    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22705    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22706    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22707    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22708    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22709    @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; } }
22710    .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;}
22711    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22712    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22713    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22714    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22715    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22716    .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;}
22717    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22718    .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);}
22719    .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;}
22720    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22721    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22722    .settings-modal-body{padding:14px 16px 16px;}
22723    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22724    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22725    .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;}
22726    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22727    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22728    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22729    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22730    .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;}
22731    .tz-select:focus{border-color:var(--oxide);}
22732    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22733    .page-header{text-align:center;margin-bottom:16px;}
22734    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22735    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22736    /* Cards */
22737    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22738    .option-card-wrap{position:relative;}
22739    .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;}
22740    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22741    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22742    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22743    .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;}
22744    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22745    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22746    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22747    .card-top-row{display:flex;align-items:center;gap:20px;}
22748    /* Two-column layout inside each card */
22749    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22750    .card-left{display:flex;align-items:flex-start;min-width:0;}
22751    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22752    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22753    .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);}
22754    .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);}
22755    .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);}
22756    .card-text{min-width:0;}
22757    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22758    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22759    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22760    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22761    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22762    /* Right CTA column */
22763    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22764    .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;}
22765    /* Re-scan count badge */
22766    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22767    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22768    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22769    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22770    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22771    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22772    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22773    body.dark-theme .btn-secondary{color:var(--oxide);}
22774    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22775    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22776    /* File input overlay — must be full-width so it aligns with other card-right buttons */
22777    .file-input-wrap{position:relative;width:100%;}
22778    .file-input-wrap .btn{width:100%;}
22779    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22780    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22781    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22782    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22783    .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;}
22784    @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));}}
22785    /* Recent list (card 3 — full-width section below header) */
22786    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22787    .recent-list{display:flex;flex-direction:column;gap:8px;}
22788    .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;}
22789    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22790    .recent-item-info{flex:1;min-width:0;}
22791    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22792    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22793    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22794    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22795    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22796    .site-footer a{color:var(--muted);}
22797    @media(max-width:680px){
22798      .card-body{grid-template-columns:1fr;}
22799      .card-right{flex-direction:row;flex-wrap:wrap;}
22800      .btn{flex:1;}
22801    }
22802    .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;}
22803    .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;}
22804    .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;}
22805  </style>
22806</head>
22807<body>
22808  <div class="background-watermarks" aria-hidden="true">
22809    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22810    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22811    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22812    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22813    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22814    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22815    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22816  </div>
22817  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22818  <div class="top-nav">
22819    <div class="top-nav-inner">
22820      <a class="brand" href="/">
22821        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22822        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22823      </a>
22824      <div class="nav-right">
22825        <a class="nav-pill" href="/">Home</a>
22826        <div class="nav-dropdown">
22827          <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>
22828          <div class="nav-dropdown-menu">
22829            <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>
22830          </div>
22831        </div>
22832        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22833        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22834        <div class="nav-dropdown">
22835          <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>
22836          <div class="nav-dropdown-menu">
22837            <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>
22838          </div>
22839        </div>
22840        <div class="server-status-wrap" id="server-status-wrap">
22841          <div class="nav-pill server-online-pill" id="server-status-pill">
22842            <span class="status-dot" id="status-dot"></span>
22843            <span id="server-status-label">Server</span>
22844            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22845          </div>
22846          <div class="server-status-tip">
22847            OxideSLOC is running — accessible on your network.
22848            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22849          </div>
22850        </div>
22851        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22852          <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>
22853        </button>
22854        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22855          <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>
22856          <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>
22857        </button>
22858      </div>
22859    </div>
22860  </div>
22861
22862  <div class="page">
22863    <div class="page-header">
22864      <h1>How would you like to scan?</h1>
22865      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22866    </div>
22867
22868    <div class="option-grid">
22869
22870      <!-- Option 1: New scan -->
22871      <div class="option-card-wrap">
22872        <div class="option-card">
22873        <div class="option-icon new-scan">
22874          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22875        </div>
22876        <div class="card-body">
22877          <div class="card-left">
22878            <div class="card-text">
22879              <div class="option-title">Start a new scan</div>
22880              <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>
22881              <ul class="feature-list">
22882                <li>Live project scope preview before you run</li>
22883                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22884                <li>HTML, PDF, and JSON output — your choice</li>
22885              </ul>
22886            </div>
22887          </div>
22888          <div class="card-right">
22889            <a class="btn btn-primary" href="/scan">
22890              Configure &amp; scan
22891              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22892            </a>
22893            <p class="card-tip">Full 4-step setup · all options</p>
22894          </div>
22895        </div>
22896        </div>
22897      </div>
22898
22899      <!-- Option 2: Load from config file -->
22900      <div class="option-card-wrap">
22901        <div class="option-card">
22902        <div class="option-icon load-config">
22903          <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>
22904        </div>
22905        <div class="card-body">
22906          <div class="card-left">
22907            <div class="card-text">
22908              <div class="option-title">Load a saved config</div>
22909              <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>
22910              <ul class="feature-list">
22911                <li>All 15 settings restored from the file</li>
22912                <li>Fully editable — change path or output dir</li>
22913                <li>Works with any scan-config.json</li>
22914              </ul>
22915            </div>
22916          </div>
22917          <div class="card-right">
22918            <div class="file-input-wrap">
22919              <button class="btn btn-secondary" id="load-config-btn" type="button">
22920                <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>
22921                Choose config file
22922              </button>
22923              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22924            </div>
22925            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22926          </div>
22927        </div>
22928        </div>
22929      </div>
22930
22931      <!-- Option 3: Re-scan recent project -->
22932      <div class="option-card-wrap">
22933        <div class="option-card" id="recent-card">
22934        <div class="card-top-row">
22935          <div class="option-icon rescan">
22936            <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>
22937          </div>
22938          <div class="card-body">
22939            <div class="card-left">
22940              <div class="card-text">
22941                <div class="option-title">Re-scan a recent project</div>
22942                <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>
22943                <ul class="feature-list">
22944                  <li>All 15+ settings restored from the saved config</li>
22945                  <li>Path and output dir are editable before running</li>
22946                  <li>Only scans with a saved config appear here</li>
22947                </ul>
22948              </div>
22949            </div>
22950            <div class="card-right">
22951              <div class="rescan-count-box">
22952                <div class="rescan-count-num" id="rescan-count-num">—</div>
22953                <div class="rescan-count-label">saved configs</div>
22954              </div>
22955              <a class="btn btn-secondary" href="/view-reports">
22956                <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>
22957                View all runs
22958              </a>
22959              <p class="card-tip">Opens run history</p>
22960            </div>
22961          </div>
22962        </div>
22963        <div class="section-divider"></div>
22964        <div class="recent-list" id="recent-list">
22965          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22966        </div>
22967        </div>
22968      </div>
22969
22970    </div>
22971  </div>
22972
22973  <footer class="site-footer">
22974    local code analysis - metrics, history and reports
22975    &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>
22976    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22977    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22978    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22979    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22980  </footer>
22981
22982  <script nonce="{{ csp_nonce }}">
22983    (function () {
22984      var storageKey = 'oxide-sloc-theme';
22985      var body = document.body;
22986      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22987      var toggle = document.getElementById('theme-toggle');
22988      if (toggle) toggle.addEventListener('click', function () {
22989        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22990        body.classList.toggle('dark-theme', next === 'dark');
22991        try { localStorage.setItem(storageKey, next); } catch(e) {}
22992      });
22993
22994      (function randomizeWatermarks() {
22995        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22996        if (!wms.length) return;
22997        var placed = [];
22998        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; }
22999        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]; }
23000        var half = Math.floor(wms.length / 2);
23001        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; });
23002      })();
23003      (function spawnCodeParticles() {
23004        var container = document.getElementById('code-particles');
23005        if (!container) return;
23006        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'];
23007        var count = 38;
23008        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); }
23009      })();
23010      // Recent scans data injected from server
23011      var recentScans = {{ recent_scans_json|safe }};
23012
23013      function configToParams(cfg) {
23014        var p = new URLSearchParams();
23015        p.set('prefilled', '1');
23016        if (cfg.path) p.set('path', cfg.path);
23017        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23018        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23019        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23020        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23021        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23022        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23023        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23024        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23025        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23026        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23027        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23028        if (cfg.report_title) p.set('report_title', cfg.report_title);
23029        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23030        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23031        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23032        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23033        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23034        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23035        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23036        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23037        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23038        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23039        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23040        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23041        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23042        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23043        return p;
23044      }
23045
23046      // Build recent scan list (capped at 3 visible entries)
23047      var list = document.getElementById('recent-list');
23048      var noNote = document.getElementById('no-recent-note');
23049      var hasAny = false;
23050      var MAX_RECENT = 3;
23051      if (Array.isArray(recentScans)) {
23052        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23053        var shown = 0;
23054        validEntries.forEach(function (entry) {
23055          if (shown >= MAX_RECENT) return;
23056          shown++;
23057          hasAny = true;
23058          var item = document.createElement('div');
23059          item.className = 'recent-item';
23060          item.title = 'Restore all settings and open wizard';
23061          item.innerHTML =
23062            '<div class="recent-item-info">' +
23063              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23064              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
23065            '</div>' +
23066            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23067          item.addEventListener('click', function () {
23068            var params = configToParams(entry.config);
23069            window.location.href = '/scan?' + params.toString();
23070          });
23071          list.appendChild(item);
23072        });
23073        if (validEntries.length > MAX_RECENT) {
23074          var moreEl = document.createElement('div');
23075          moreEl.className = 'recent-more-link';
23076          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
23077          list.appendChild(moreEl);
23078        }
23079      }
23080      if (hasAny && noNote) noNote.style.display = 'none';
23081      // Update count badge
23082      var countEl = document.getElementById('rescan-count-num');
23083      if (countEl) {
23084        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23085        countEl.textContent = total > 0 ? total : '0';
23086      }
23087
23088      // Config file loader
23089      var fileInput = document.getElementById('config-file-input');
23090      var fileName = document.getElementById('config-file-name');
23091      var loadBtn = document.getElementById('load-config-btn');
23092      // Wire the visible button to open the hidden file picker.
23093      if (loadBtn && fileInput) {
23094        loadBtn.addEventListener('click', function () { fileInput.click(); });
23095      }
23096      if (fileInput) {
23097        fileInput.addEventListener('change', function () {
23098          var file = fileInput.files && fileInput.files[0];
23099          if (!file) return;
23100          if (fileName) fileName.textContent = '\u2713 ' + file.name;
23101          var reader = new FileReader();
23102          reader.onload = function (e) {
23103            try {
23104              var cfg = JSON.parse(e.target.result);
23105              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23106              var params = configToParams(cfg);
23107              window.location.href = '/scan?' + params.toString();
23108            } catch (err) {
23109              alert('Could not parse config file: ' + err.message);
23110            }
23111          };
23112          reader.readAsText(file);
23113        });
23114      }
23115
23116      function escHtml(s) {
23117        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
23118      }
23119    })();
23120  </script>
23121  <script nonce="{{ csp_nonce }}">
23122  (function(){
23123    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'}];
23124    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);});}
23125    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23126    function init(){
23127      var btn=document.getElementById('settings-btn');if(!btn)return;
23128      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23129      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>';
23130      document.body.appendChild(m);
23131      var g=document.getElementById('scheme-grid');
23132      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);});
23133      var cl=document.getElementById('settings-close');
23134      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);});})();
23135      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');});
23136      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23137      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23138    }
23139    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23140  }());
23141  </script>
23142  <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]';
23143  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;}
23144  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>
23145</body>
23146</html>
23147"##,
23148    ext = "html"
23149)]
23150struct ScanSetupTemplate {
23151    version: &'static str,
23152    recent_scans_json: String,
23153    csp_nonce: String,
23154}
23155
23156#[derive(Template)]
23157#[template(
23158    source = r##"
23159<!doctype html>
23160<html lang="en">
23161<head>
23162  <meta charset="utf-8">
23163  <meta name="viewport" content="width=device-width, initial-scale=1">
23164  <title>OxideSLOC | {{ report_title }} | Report</title>
23165  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23166  <style nonce="{{ csp_nonce }}">
23167    :root {
23168      --radius: 18px;
23169      --bg: #f5efe8;
23170      --surface: rgba(255,255,255,0.82);
23171      --surface-2: #fbf7f2;
23172      --surface-3: #efe6dc;
23173      --line: #e6d0bf;
23174      --line-strong: #dcb89f;
23175      --text: #43342d;
23176      --muted: #7b675b;
23177      --muted-2: #a08777;
23178      --nav: #b85d33;
23179      --nav-2: #7a371b;
23180      --accent: #6f9bff;
23181      --accent-2: #4a78ee;
23182      --oxide: #d37a4c;
23183      --oxide-2: #b35428;
23184      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23185      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23186      --success-bg: #e8f5ed;
23187      --success-text: #1a8f47;
23188      --info-bg: #eef3ff;
23189      --info-text: #4467d8;
23190    }
23191
23192    body.dark-theme {
23193      --bg: #1b1511;
23194      --surface: #261c17;
23195      --surface-2: #2d221d;
23196      --surface-3: #372922;
23197      --line: #524238;
23198      --line-strong: #6c5649;
23199      --text: #f5ece6;
23200      --muted: #c7b7aa;
23201      --muted-2: #aa9485;
23202      --nav: #b85d33;
23203      --nav-2: #7a371b;
23204      --accent: #6f9bff;
23205      --accent-2: #4a78ee;
23206      --oxide: #d37a4c;
23207      --oxide-2: #b35428;
23208      --shadow: 0 18px 42px rgba(0,0,0,0.28);
23209      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23210      --success-bg: #163927;
23211      --success-text: #8fe2a8;
23212      --info-bg: #1c2847;
23213      --info-text: #a9c1ff;
23214    }
23215
23216    * { box-sizing: border-box; }
23217    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); }
23218    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23219    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23220    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23221    .top-nav, .page { position: relative; z-index: 2; }
23222    .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); }
23223    .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; }
23224    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23225    .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)); }
23226    .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; }
23227    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23228    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23229    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23230    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23231    .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; }
23232    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23233    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23234    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23235    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23236    @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; } }
23237    .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; }
23238    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23239    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23240    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23241    .theme-toggle .icon-sun { display:none; }
23242    body.dark-theme .theme-toggle .icon-sun { display:block; }
23243    body.dark-theme .theme-toggle .icon-moon { display:none; }
23244    .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;}
23245    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23246    .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);}
23247    .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;}
23248    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23249    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23250    .settings-modal-body{padding:14px 16px 16px;}
23251    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23252    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23253    .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;}
23254    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23255    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23256    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23257    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23258    .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;}
23259    .tz-select:focus{border-color:var(--oxide);}
23260    .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; }
23261    .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;}
23262    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23263    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23264    .hero, .panel { padding: 22px; }
23265    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23266    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23267    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23268    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23269    .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; }
23270    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23271    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23272    .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; }
23273    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23274    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23275    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23276    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23277    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23278    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23279    .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); }
23280    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23281    .delta-card-val { font-size:16px; font-weight:800; }
23282    .delta-card-val.pos { color:#1e7e34; }
23283    .delta-card-val.neg { color:var(--neg); }
23284    .delta-card-val.mod { color:#b35428; }
23285    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23286    .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; }
23287    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23288    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23289    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23290    .compare-ts { font-size:13px; color:var(--muted); }
23291    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23292    .compare-arrow { color: var(--muted); }
23293    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23294    .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; }
23295    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23296    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23297    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23298    .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; }
23299    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23300    .run-mgmt-card .action-buttons { justify-content:center; }
23301    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23302    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23303    .button, .copy-button {
23304      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;
23305    }
23306    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23307    @keyframes spin { to { transform: rotate(360deg); } }
23308    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23309    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23310    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23311    .path-item strong { display: block; margin-bottom: 6px; }
23312    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23313    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23314    .path-subitem { flex: 1; }
23315    .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); }
23316    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); }
23317    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23318    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23319    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23320    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23321    th { color: var(--muted); font-weight: 700; }
23322    tr:last-child td { border-bottom: none; }
23323    #subm-tbl col:nth-child(1){width:15%;}
23324    #subm-tbl col:nth-child(2){width:31%;}
23325    #subm-tbl col:nth-child(3){width:9%;}
23326    #subm-tbl col:nth-child(4){width:9%;}
23327    #subm-tbl col:nth-child(5){width:9%;}
23328    #subm-tbl col:nth-child(6){width:9%;}
23329    #subm-tbl col:nth-child(7){width:9%;}
23330    #subm-tbl col:nth-child(8){width:9%;}
23331    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23332    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23333    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23334    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23335    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23336    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23337    .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; }
23338    .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; }
23339    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23340    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23341    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23342    .muted { color: var(--muted); }
23343    /* Run-ID chip row (mirrors HTML report) */
23344    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23345    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23346    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23347    .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; }
23348    .run-id-chip[data-copy] { cursor:pointer; }
23349    a.run-id-chip { text-decoration:none; cursor:pointer; }
23350    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23351    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23352    .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; }
23353    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23354    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23355    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23356    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23357    a.commit-link-value { color:inherit; text-decoration:none; }
23358    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23359    .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; }
23360    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23361    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23362    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23363    .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; }
23364    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23365    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23366    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23367    /* Meta chips row */
23368    .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%; }
23369    .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; }
23370    .meta-chip:last-child { border-right:none; }
23371    .meta-chip b { color:var(--text); font-weight:700; }
23372    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23373    .site-footer a{color:var(--muted);}
23374    .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; }
23375    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23376    .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; }
23377    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23378    /* Stat chips (matches HTML report) */
23379    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23380    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23381    /* Hero stat strip: uniform grid where every card is the same width and the
23382       columns line up across both rows. JS sets the column count to ceil(n/2) so
23383       the cards always occupy exactly two rows; when the count is odd the last
23384       card spans two columns to fill the trailing cell with no empty gap. */
23385    .summary-strip-hero { align-items:stretch; }
23386    .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; }
23387    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23388    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23389    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23390    .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; }
23391    .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); }
23392    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23393    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23394    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23395    .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; }
23396    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23397    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23398    .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); }
23399    .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); }
23400    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23401    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23402    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23403    /* Submodule panel */
23404    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23405    /* Metrics tables stack */
23406    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23407    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23408    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23409    .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)); }
23410    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23411    /* Metrics table */
23412    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23413    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23414    .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; }
23415    .metrics-table thead th:not(:first-child) { text-align: right; }
23416    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23417    .metrics-table tbody tr:last-child td { border-bottom: none; }
23418    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23419    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23420    .metrics-table tbody tr:hover td { background: var(--surface-2); }
23421    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23422    .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; }
23423    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23424    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23425    .mt-val-pos { color: var(--pos); font-weight: 700; }
23426    .mt-val-neg { color: var(--neg); font-weight: 700; }
23427    .mt-val-zero { color: var(--muted); }
23428    .mt-val-mod { color: var(--oxide-2); }
23429    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23430    @media (max-width: 1180px) {
23431      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23432      .nav-project-slot, .nav-status { justify-content:flex-start; }
23433      .hero-top { flex-direction: column; }
23434      .run-mgmt-strip { flex-direction: column; }
23435    }
23436    .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;}
23437    @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));}}
23438    .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;}
23439    /* ── Result-page chart controls ─────────────────────────────────────────── */
23440    .r-chart-section{margin-bottom:24px;}
23441    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23442    .section-pair > .panel{flex-shrink:0;}
23443    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23444    .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;}
23445    .r-chart-select:focus{border-color:var(--accent);}
23446    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23447    .r-chart-container svg{display:block;width:100%;height:auto;}
23448    .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;}
23449    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23450    .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;}
23451    .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);}
23452    .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;}
23453    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23454    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23455    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23456    .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;}
23457    .r-chart-modal-close:hover{opacity:.7;}
23458    body.dark-theme .r-chart-modal{background:var(--surface);}
23459    .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;}
23460    .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);}
23461    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23462    .lang-bar-row:hover{transform:translateY(-2px);}
23463    .lang-bar-row .rchit:hover{filter:none;transform:none;}
23464    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23465    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23466    .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;}
23467    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23468    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23469    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23470    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23471    #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;}
23472    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23473    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23474    .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;}
23475    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23476    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23477    .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;}
23478    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23479    .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;}
23480    .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;}
23481    body.has-report-banner .top-nav{top:27px;}
23482    body.has-report-banner{padding-bottom:27px;}
23483  </style>
23484</head>
23485<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23486  <div class="background-watermarks" aria-hidden="true">
23487    <img src="/images/logo/logo-text.png" alt="" />
23488    <img src="/images/logo/logo-text.png" alt="" />
23489    <img src="/images/logo/logo-text.png" alt="" />
23490    <img src="/images/logo/logo-text.png" alt="" />
23491    <img src="/images/logo/logo-text.png" alt="" />
23492    <img src="/images/logo/logo-text.png" alt="" />
23493    <img src="/images/logo/logo-text.png" alt="" />
23494    <img src="/images/logo/logo-text.png" alt="" />
23495    <img src="/images/logo/logo-text.png" alt="" />
23496    <img src="/images/logo/logo-text.png" alt="" />
23497    <img src="/images/logo/logo-text.png" alt="" />
23498    <img src="/images/logo/logo-text.png" alt="" />
23499    <img src="/images/logo/logo-text.png" alt="" />
23500    <img src="/images/logo/logo-text.png" alt="" />
23501  </div>
23502  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23503  {% if let Some(banner) = report_header_footer %}
23504  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23505  {% endif %}
23506  <div class="top-nav">
23507    <div class="top-nav-inner">
23508      <a class="brand" href="/">
23509        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23510        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23511      </a>
23512      <div class="nav-project-slot">
23513        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23514      </div>
23515      <div class="nav-status">
23516        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23517        <div class="nav-dropdown">
23518          <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>
23519          <div class="nav-dropdown-menu">
23520            <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>
23521          </div>
23522        </div>
23523        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23524        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23525        <div class="nav-dropdown">
23526          <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>
23527          <div class="nav-dropdown-menu">
23528            <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>
23529          </div>
23530        </div>
23531        <div class="server-status-wrap" id="server-status-wrap">
23532          <div class="nav-pill server-online-pill" id="server-status-pill">
23533            <span class="status-dot" id="status-dot"></span>
23534            <span id="server-status-label">Server</span>
23535            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23536          </div>
23537          <div class="server-status-tip">
23538            OxideSLOC is running — accessible on your network.
23539            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23540          </div>
23541        </div>
23542        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23543          <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>
23544        </button>
23545        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23546          <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>
23547          <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>
23548        </button>
23549      </div>
23550    </div>
23551  </div>
23552
23553  <div class="page">
23554    <section class="hero">
23555      <div class="hero-top">
23556        <div>
23557          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23558            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23559            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23560            <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>
23561          </div>
23562        </div>
23563        <div class="hero-quick-actions">
23564          {% if server_mode %}
23565          <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>
23566          {% else %}
23567          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23568          {% endif %}
23569          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23570          {% if !server_mode %}
23571          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23572          {% endif %}
23573          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23574          <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>
23575        </div>
23576      </div>
23577
23578      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23579      <div class="run-id-row">
23580        <span class="run-id-chip" data-copy="{{ run_id }}">
23581          <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>
23582          <span class="run-id-chip-value">{{ run_id }}</span>
23583          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23584        </span>
23585        {% match git_commit_long %}
23586          {% when Some with (long_sha) %}
23587          {% match git_commit_url %}
23588            {% when Some with (commit_url) %}
23589            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23590              <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>
23591              <span class="run-id-chip-value">{{ long_sha }}</span>
23592              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23593            </a>
23594            {% when None %}
23595            <span class="run-id-chip" data-copy="{{ long_sha }}">
23596              <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>
23597              <span class="run-id-chip-value">{{ long_sha }}</span>
23598              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23599            </span>
23600          {% endmatch %}
23601          {% when None %}
23602          <span class="run-id-chip muted-chip">
23603            <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>
23604            <span class="run-id-chip-value">Not detected</span>
23605            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23606          </span>
23607        {% endmatch %}
23608        {% match git_branch %}
23609          {% when Some with (branch) %}
23610          {% match git_branch_url %}
23611            {% when Some with (branch_url) %}
23612            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23613              <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>
23614              <span class="run-id-chip-value">{{ branch }}</span>
23615              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23616            </a>
23617            {% when None %}
23618            <span class="run-id-chip">
23619              <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>
23620              <span class="run-id-chip-value">{{ branch }}</span>
23621              <span class="chip-tooltip">Git branch active at scan time</span>
23622            </span>
23623          {% endmatch %}
23624          {% when None %}
23625          <span class="run-id-chip muted-chip">
23626            <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>
23627            <span class="run-id-chip-value">Not detected</span>
23628            <span class="chip-tooltip">No Git branch was found for this scan</span>
23629          </span>
23630        {% endmatch %}
23631        {% match git_author %}
23632          {% when Some with (author) %}
23633          <span class="run-id-chip" data-author="{{ author }}">
23634            <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>
23635            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23636            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23637          </span>
23638          {% when None %}
23639          <span class="run-id-chip muted-chip">
23640            <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>
23641            <span class="run-id-chip-value">Not detected</span>
23642            <span class="chip-tooltip">No commit author was found for this scan</span>
23643          </span>
23644        {% endmatch %}
23645      </div>
23646
23647      <!-- Scan metadata row -->
23648      <div class="meta">
23649        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23650        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23651        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23652        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23653        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23654      </div>
23655
23656      <!-- All summary stat chips in one unified strip (8 columns) -->
23657      <div class="summary-strip summary-strip-hero">
23658        <div class="stat-chip" data-raw="{{ physical_lines }}">
23659          <div class="stat-chip-label">Physical lines</div>
23660          <div class="stat-chip-val">{{ physical_lines }}</div>
23661          <div class="stat-chip-exact"></div>
23662          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23663        </div>
23664        <div class="stat-chip" data-raw="{{ code_lines }}">
23665          <div class="stat-chip-label">Code</div>
23666          <div class="stat-chip-val">{{ code_lines }}</div>
23667          <div class="stat-chip-exact"></div>
23668          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23669        </div>
23670        <div class="stat-chip" data-raw="{{ comment_lines }}">
23671          <div class="stat-chip-label">Comments</div>
23672          <div class="stat-chip-val">{{ comment_lines }}</div>
23673          <div class="stat-chip-exact"></div>
23674          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23675        </div>
23676        <div class="stat-chip" data-raw="{{ blank_lines }}">
23677          <div class="stat-chip-label">Blank</div>
23678          <div class="stat-chip-val">{{ blank_lines }}</div>
23679          <div class="stat-chip-exact"></div>
23680          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23681        </div>
23682        <div class="stat-chip" data-raw="{{ mixed_lines }}">
23683          <div class="stat-chip-label">Mixed separate</div>
23684          <div class="stat-chip-val">{{ mixed_lines }}</div>
23685          <div class="stat-chip-exact"></div>
23686          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23687        </div>
23688        <div class="stat-chip" data-raw="{{ functions }}">
23689          <div class="stat-chip-label">Functions</div>
23690          <div class="stat-chip-val">{{ functions }}</div>
23691          <div class="stat-chip-exact"></div>
23692          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23693        </div>
23694        <div class="stat-chip" data-raw="{{ classes }}">
23695          <div class="stat-chip-label">Classes / Types</div>
23696          <div class="stat-chip-val">{{ classes }}</div>
23697          <div class="stat-chip-exact"></div>
23698          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23699        </div>
23700        <div class="stat-chip" data-raw="{{ variables }}">
23701          <div class="stat-chip-label">Variables</div>
23702          <div class="stat-chip-val">{{ variables }}</div>
23703          <div class="stat-chip-exact"></div>
23704          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23705        </div>
23706        <div class="stat-chip" data-raw="{{ imports }}">
23707          <div class="stat-chip-label">Imports</div>
23708          <div class="stat-chip-val">{{ imports }}</div>
23709          <div class="stat-chip-exact"></div>
23710          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23711        </div>
23712        <div class="stat-chip" data-raw="{{ test_count }}">
23713          <div class="stat-chip-label">Tests</div>
23714          <div class="stat-chip-val">{{ test_count }}</div>
23715          <div class="stat-chip-exact"></div>
23716          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23717        </div>
23718        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23719          <div class="stat-chip-label">Code density</div>
23720          <div class="stat-chip-val stat-chip-density-val">—</div>
23721          <div class="stat-chip-exact"></div>
23722          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23723        </div>
23724        <div class="stat-chip" data-raw="{{ files_analyzed }}">
23725          <div class="stat-chip-label">Files analyzed</div>
23726          <div class="stat-chip-val">{{ files_analyzed }}</div>
23727          <div class="stat-chip-exact"></div>
23728          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23729        </div>
23730        {% if cyclomatic_complexity > 0 %}
23731        <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 %}>
23732          <div class="stat-chip-label">Complexity score</div>
23733          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23734          <div class="stat-chip-exact"></div>
23735          <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>
23736        </div>
23737        {% endif %}
23738        {% if let Some(ls) = lsloc %}
23739        <div class="stat-chip" data-raw="{{ ls }}">
23740          <div class="stat-chip-label">Logical SLOC</div>
23741          <div class="stat-chip-val">{{ ls }}</div>
23742          <div class="stat-chip-exact"></div>
23743          <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>
23744        </div>
23745        {% endif %}
23746        {% if uloc > 0 %}
23747        <div class="stat-chip" data-raw="{{ uloc }}">
23748          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23749          <div class="stat-chip-val">{{ uloc }}</div>
23750          <div class="stat-chip-exact"></div>
23751          <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>
23752        </div>
23753        {% endif %}
23754        {% if uloc > 0 && dryness_pct_str != "" %}
23755        <div class="stat-chip">
23756          <div class="stat-chip-label">DRYness</div>
23757          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23758          <div class="stat-chip-exact"></div>
23759          <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>
23760        </div>
23761        {% endif %}
23762        {% if duplicate_group_count > 0 %}
23763        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23764          <div class="stat-chip-label">Duplicate groups</div>
23765          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23766          <div class="stat-chip-exact"></div>
23767          <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>
23768        </div>
23769        {% endif %}
23770        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23771             odd, so the strip always forms exactly two full rows with every column
23772             aligned and every card the same width (no oversized card, no gap). -->
23773        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23774          <div class="stat-chip-label">Assertions</div>
23775          <div class="stat-chip-val">{{ test_assertion_count }}</div>
23776          <div class="stat-chip-exact"></div>
23777          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23778        </div>
23779      </div>
23780
23781      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23782      <div class="compare-banner">
23783        <div class="compare-banner-body">
23784          <div class="compare-banner-top">
23785          <div class="compare-banner-meta">
23786            <span class="compare-label">Previous scan</span>
23787            <span class="compare-ts">{{ prev_ts }}</span>
23788            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23789            {% if let Some(prev_code) = prev_run_code_lines %}
23790            <div class="compare-banner-stats" style="margin-top:4px;">
23791              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23792              <span class="compare-arrow">→</span>
23793              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23794              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23795              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23796            </div>
23797            {% endif %}
23798          </div>
23799          {% if delta_lines_added.is_some() %}
23800          <div class="delta-cards-inline">
23801            <div class="delta-card-inline">
23802              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23803              <div class="delta-card-lbl">lines added</div>
23804              <div class="delta-card-tip">Code lines added since the previous scan</div>
23805            </div>
23806            <div class="delta-card-inline">
23807              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23808              <div class="delta-card-lbl">lines removed</div>
23809              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23810            </div>
23811            <div class="delta-card-inline">
23812              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23813              <div class="delta-card-lbl">unmodified lines</div>
23814              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23815            </div>
23816            <div class="delta-card-inline">
23817              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23818              <div class="delta-card-lbl">files modified</div>
23819              <div class="delta-card-tip">Files with at least one line changed</div>
23820            </div>
23821            <div class="delta-card-inline">
23822              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23823              <div class="delta-card-lbl">files added</div>
23824              <div class="delta-card-tip">New files added since the previous scan</div>
23825            </div>
23826            <div class="delta-card-inline">
23827              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23828              <div class="delta-card-lbl">files removed</div>
23829              <div class="delta-card-tip">Files deleted since the previous scan</div>
23830            </div>
23831            <div class="delta-card-inline">
23832              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23833              <div class="delta-card-lbl">files unchanged</div>
23834              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23835            </div>
23836            <div class="delta-card-inline">
23837              <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
23838              <div class="delta-card-lbl">files total</div>
23839              <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
23840            </div>
23841          </div>
23842          {% else %}
23843          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23844            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23845          </p>
23846          {% endif %}
23847          </div>
23848          <div class="compare-banner-actions">
23849            <div class="compare-banner-actions-left">
23850              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23851              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23852            </div>
23853            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23854          </div>
23855        </div>
23856      </div>
23857      {% endif %}{% endif %}
23858
23859      <div class="action-grid">
23860        <div class="action-card">
23861          <h3>HTML report</h3>
23862          <div class="action-buttons">
23863            {% match html_url %}
23864              {% when Some with (url) %}
23865                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23866              {% when None %}{% endmatch %}
23867            {% match html_download_url %}
23868              {% when Some with (url) %}
23869                <a class="button secondary" href="{{ url }}">Download HTML</a>
23870              {% when None %}{% endmatch %}
23871            {% match html_path %}
23872              {% when Some with (_path) %}{% when None %}{% endmatch %}
23873            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23874          </div>
23875        </div>
23876        <div class="action-card">
23877          <h3>PDF report</h3>
23878          <div class="action-buttons">
23879            {% match pdf_url %}
23880              {% when Some with (url) %}
23881                {% if pdf_generating %}
23882                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23883                    <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>
23884                    Generating PDF…
23885                  </button>
23886                {% else %}
23887                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23888                {% endif %}
23889              {% when None %}
23890                {% match html_url %}
23891                  {% when Some with (_hurl) %}
23892                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23893                    <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>
23894                  {% when None %}
23895                    <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;">
23896                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23897                    </p>
23898                {% endmatch %}
23899            {% endmatch %}
23900            {% match pdf_download_url %}
23901              {% when Some with (url) %}
23902                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23903              {% when None %}{% endmatch %}
23904            {% match pdf_url %}
23905              {% when Some with (_) %}
23906                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23907              {% when None %}{% endmatch %}
23908          </div>
23909        </div>
23910        <div class="action-card">
23911          <h3>JSON result</h3>
23912          <div class="action-buttons">
23913            {% match json_url %}
23914              {% when Some with (url) %}
23915                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23916              {% when None %}{% endmatch %}
23917            {% match json_download_url %}
23918              {% when Some with (url) %}
23919                <a class="button secondary" href="{{ url }}">Download JSON</a>
23920              {% when None %}{% endmatch %}
23921            {% match json_path %}
23922              {% when Some with (_path) %}
23923                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23924              {% when None %}
23925                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23926              {% endmatch %}
23927          </div>
23928        </div>
23929        <div class="action-card">
23930          <h3>Scan config</h3>
23931          <div class="action-buttons">
23932            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23933            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23934            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23935          </div>
23936        </div>
23937        {% if confluence_configured %}
23938        <div class="action-card" id="confluenceCard">
23939          <h3>Confluence</h3>
23940          <div class="action-buttons">
23941            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23942            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23943          </div>
23944          <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>
23945        </div>
23946        {% endif %}
23947      </div>
23948      {% if confluence_configured %}
23949      <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;">
23950        <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);">
23951          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23952          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23953          <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;">
23954          <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>
23955          <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;">
23956          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23957          <div style="display:flex;gap:10px;justify-content:flex-end;">
23958            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23959            <button class="button" id="confSubmitBtn" type="button">Post</button>
23960          </div>
23961        </div>
23962      </div>
23963      {% endif %}
23964      <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;">
23965        <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);">
23966          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23967          <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>
23968          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23969          <div style="display:flex;gap:18px;justify-content:flex-end;">
23970            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23971            <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>
23972          </div>
23973        </div>
23974      </div>
23975      {% if !submodule_rows.is_empty() %}
23976      <div class="submodule-panel">
23977        <div class="toolbar-row">
23978          <div>
23979            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23980            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23981          </div>
23982          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23983        </div>
23984        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23985        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23986          <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>
23987          <thead>
23988            <tr>
23989              <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>
23990              <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>
23991              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Files</th>
23992              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Physical</th>
23993              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Code</th>
23994              <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>
23995              <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>
23996              <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>
23997            </tr>
23998          </thead>
23999          <tbody>
24000            {% for row in submodule_rows %}
24001            <tr>
24002              <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>
24003              <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>
24004              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24005              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24006              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24007              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24008              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24009              <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>
24010            </tr>
24011            {% endfor %}
24012          </tbody>
24013        </table>
24014        </div>
24015      </div>
24016      {% endif %}
24017
24018      <div class="metrics-tables-stack">
24019
24020        <div class="metrics-table-wrap">
24021          <div class="metrics-table-title">Files</div>
24022          <table class="metrics-table">
24023            <thead>
24024              <tr>
24025                <th>Metric</th>
24026                <th>This Run</th>
24027                <th>Previous</th>
24028                <th>Change</th>
24029              </tr>
24030            </thead>
24031            <tbody>
24032              <tr>
24033                <td>Files analyzed</td>
24034                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24035                <td>{{ prev_fa_str|commas }}</td>
24036                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24037              </tr>
24038              <tr>
24039                <td>Files skipped</td>
24040                <td>{{ files_skipped|commas }}</td>
24041                <td>{{ prev_fs_str|commas }}</td>
24042                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24043              </tr>
24044              <tr>
24045                <td>Files modified</td>
24046                <td class="mt-val-na">—</td>
24047                <td class="mt-val-na">—</td>
24048                <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>
24049              </tr>
24050              <tr>
24051                <td>Files unchanged</td>
24052                <td class="mt-val-na">—</td>
24053                <td class="mt-val-na">—</td>
24054                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24055              </tr>
24056              <tr>
24057                <td>Files total</td>
24058                <td class="mt-val-na">—</td>
24059                <td class="mt-val-na">—</td>
24060                <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24061              </tr>
24062            </tbody>
24063          </table>
24064        </div>
24065
24066        <div class="metrics-table-wrap">
24067          <div class="metrics-table-title">Line Counts</div>
24068          <table class="metrics-table">
24069            <thead>
24070              <tr>
24071                <th>Metric</th>
24072                <th>This Run</th>
24073                <th>Previous</th>
24074                <th>Change</th>
24075              </tr>
24076            </thead>
24077            <tbody>
24078              <tr>
24079                <td>Physical lines</td>
24080                <td class="mt-val-large">{{ physical_lines|commas }}</td>
24081                <td>{{ prev_pl_str|commas }}</td>
24082                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24083              </tr>
24084              <tr>
24085                <td>Code lines</td>
24086                <td class="mt-val-large">{{ code_lines|commas }}</td>
24087                <td>{{ prev_cl_str|commas }}</td>
24088                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24089              </tr>
24090              <tr>
24091                <td>Comment lines</td>
24092                <td>{{ comment_lines|commas }}</td>
24093                <td>{{ prev_cml_str|commas }}</td>
24094                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24095              </tr>
24096              <tr>
24097                <td>Blank lines</td>
24098                <td>{{ blank_lines|commas }}</td>
24099                <td>{{ prev_bl_str|commas }}</td>
24100                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24101              </tr>
24102              <tr>
24103                <td>Mixed (separate)</td>
24104                <td>{{ mixed_lines|commas }}</td>
24105                <td class="mt-val-na">—</td>
24106                <td class="mt-val-na">—</td>
24107              </tr>
24108            </tbody>
24109          </table>
24110        </div>
24111
24112        <div class="metrics-tables-lower">
24113          <div class="metrics-table-wrap">
24114            <div class="metrics-table-title">Code Structure</div>
24115            <table class="metrics-table">
24116              <thead>
24117                <tr>
24118                  <th>Metric</th>
24119                  <th>This Run</th>
24120                </tr>
24121              </thead>
24122              <tbody>
24123                <tr>
24124                  <td>Functions</td>
24125                  <td>{{ functions|commas }}</td>
24126                </tr>
24127                <tr>
24128                  <td>Classes / Types</td>
24129                  <td>{{ classes|commas }}</td>
24130                </tr>
24131                <tr>
24132                  <td>Variables</td>
24133                  <td>{{ variables|commas }}</td>
24134                </tr>
24135                <tr>
24136                  <td>Imports</td>
24137                  <td>{{ imports|commas }}</td>
24138                </tr>
24139              </tbody>
24140            </table>
24141          </div>
24142
24143          <div class="metrics-table-wrap">
24144            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24145            <table class="metrics-table">
24146              <thead>
24147                <tr>
24148                  <th>Metric</th>
24149                  <th>Change</th>
24150                </tr>
24151              </thead>
24152              <tbody>
24153                <tr>
24154                  <td>Lines added</td>
24155                  <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>
24156                </tr>
24157                <tr>
24158                  <td>Lines removed</td>
24159                  <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>
24160                </tr>
24161                <tr>
24162                  <td>Lines modified (net)</td>
24163                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24164                </tr>
24165                <tr>
24166                  <td>Lines unmodified</td>
24167                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24168                </tr>
24169              </tbody>
24170            </table>
24171          </div>
24172        </div>
24173
24174      </div>
24175
24176      <div class="path-list">
24177        <div class="path-item">
24178          <div class="path-item-label">Project path</div>
24179          {% 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 %}
24180        </div>
24181        <div class="path-item">
24182          <div class="path-item-label">Git branch</div>
24183          {% if let Some(branch) = git_branch %}
24184          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24185          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24186          {% else %}
24187          <code style="color:var(--muted)">—</code>
24188          {% endif %}
24189        </div>
24190        <div class="path-item">
24191          <div class="path-item-label">Output folder</div>
24192          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24193        </div>
24194        <div class="path-item">
24195          <div class="path-item-label">Run ID</div>
24196          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24197            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24198            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24199          </div>
24200        </div>
24201      </div>
24202    </section>
24203
24204    {% if has_cocomo %}
24205    <div class="cocomo-box" style="margin-top:24px;">
24206      <div class="cocomo-box-head">
24207        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
24208        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24209          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24210          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24211        </span>
24212      </div>
24213      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24214        <div class="stat-chip">
24215          <div class="stat-chip-label">Person-months</div>
24216          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24217          <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>
24218        </div>
24219        <div class="stat-chip">
24220          <div class="stat-chip-label">Schedule (months)</div>
24221          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24222          <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>
24223        </div>
24224        <div class="stat-chip">
24225          <div class="stat-chip-label">Avg. Team Size</div>
24226          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24227          <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>
24228        </div>
24229        <div class="stat-chip">
24230          <div class="stat-chip-label">Input KSLOC</div>
24231          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24232          <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>
24233        </div>
24234      </div>
24235      <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>
24236    </div>
24237    {% endif %}
24238
24239    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24240    <div class="cocomo-box" style="margin-top:24px;">
24241      <div class="cocomo-box-head">
24242        <span class="cocomo-box-title">Tests &amp; Coverage</span>
24243        {% if has_coverage_data %}
24244        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24245          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24246        </span>
24247        {% endif %}
24248      </div>
24249      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24250        <div class="stat-chip">
24251          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24252          <div class="stat-chip-label">Test Functions</div>
24253          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24254        </div>
24255        <div class="stat-chip">
24256          {% if has_coverage_data %}
24257          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24258          {% else %}
24259          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24260          {% endif %}
24261          <div class="stat-chip-label">Line Coverage</div>
24262          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24263        </div>
24264        <div class="stat-chip">
24265          {% if !cov_fn_pct.is_empty() %}
24266          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24267          {% else %}
24268          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24269          {% endif %}
24270          <div class="stat-chip-label">Fn Coverage</div>
24271          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24272        </div>
24273        <div class="stat-chip">
24274          {% if !cov_branch_pct.is_empty() %}
24275          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24276          {% else %}
24277          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24278          {% endif %}
24279          <div class="stat-chip-label">Branch Coverage</div>
24280          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24281        </div>
24282      </div>
24283      {% if has_coverage_data %}
24284      <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>
24285      {% else %}
24286      <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>
24287      {% endif %}
24288    </div>
24289
24290    <div class="section-pair">
24291    <section class="panel">
24292        <div class="toolbar-row">
24293          <div>
24294            <h2>Language Breakdown</h2>
24295            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24296          </div>
24297          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24298        </div>
24299        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24300    </section>
24301
24302    <section class="panel r-chart-section">
24303      <div class="toolbar-row" style="margin-bottom:16px;">
24304        <div>
24305          <h2>Visualizations</h2>
24306          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24307        </div>
24308      </div>
24309
24310      <div class="r-viz-grid">
24311        <div class="r-viz-card">
24312          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24313            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24314            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24315          </div>
24316          <div class="r-chart-tab-bar">
24317            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24318            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24319          </div>
24320          <div class="r-chart-container" id="r-composition-chart"></div>
24321        </div>
24322        <div class="r-viz-card">
24323          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24324            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24325            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24326          </div>
24327          <div class="r-chart-container" id="r-scatter-chart"></div>
24328        </div>
24329        {% if has_semantic_data %}
24330        <div class="r-viz-card">
24331          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24332            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24333            <select class="r-chart-select" id="r-semantic-metric">
24334              <option value="functions">Functions</option>
24335              <option value="classes">Classes</option>
24336              <option value="variables">Variables</option>
24337              <option value="imports">Imports</option>
24338              <option value="tests">Tests</option>
24339            </select>
24340            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24341          </div>
24342          <div class="r-chart-container" id="r-semantic-chart"></div>
24343        </div>
24344        {% endif %}
24345        <div class="r-viz-card">
24346          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24347            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24348            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24349          </div>
24350          <div class="r-chart-container" id="r-density-chart"></div>
24351        </div>
24352        <div class="r-viz-card">
24353          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24354            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24355            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24356          </div>
24357          <div class="r-chart-container" id="r-avglines-chart"></div>
24358        </div>
24359        <div class="r-viz-card">
24360          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24361            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24362            <select class="r-chart-select" id="r-sub-metric">
24363              <option value="code">Code Lines</option>
24364              <option value="comment">Comments</option>
24365              <option value="blank">Blank Lines</option>
24366              <option value="physical">Physical Lines</option>
24367              <option value="files">Files</option>
24368            </select>
24369            <select class="r-chart-select" id="r-sub-sort">
24370              <option value="desc">Value ↓</option>
24371              <option value="asc">Value ↑</option>
24372              <option value="name">Name A→Z</option>
24373            </select>
24374            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24375          </div>
24376          <div class="r-chart-container" id="r-submodule-chart"></div>
24377        </div>
24378      </div>
24379
24380    </section>
24381    </div>
24382
24383  </div>
24384
24385  <div id="r-tt" aria-hidden="true"></div>
24386
24387  <script nonce="{{ csp_nonce }}">
24388    (function () {
24389      var body = document.body;
24390      var themeToggle = document.getElementById('theme-toggle');
24391      var storageKey = 'oxide-sloc-theme';
24392
24393      function applyTheme(theme) {
24394        body.classList.toggle('dark-theme', theme === 'dark');
24395      }
24396
24397      function loadSavedTheme() {
24398        try {
24399          var saved = localStorage.getItem(storageKey);
24400          if (saved === 'dark' || saved === 'light') {
24401            applyTheme(saved);
24402          }
24403        } catch (e) {}
24404      }
24405
24406      if (themeToggle) {
24407        themeToggle.addEventListener('click', function () {
24408          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24409          applyTheme(nextTheme);
24410          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24411        });
24412      }
24413
24414      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24415        button.addEventListener('click', function () {
24416          var value = button.getAttribute('data-copy-value') || '';
24417          if (!value) return;
24418          var originalText = button.textContent;
24419          function flashSuccess() {
24420            button.textContent = 'Copied!';
24421            setTimeout(function () { button.textContent = originalText; }, 1800);
24422          }
24423          function flashFail() {
24424            button.textContent = 'Copy failed';
24425            setTimeout(function () { button.textContent = originalText; }, 2000);
24426          }
24427          if (navigator.clipboard && navigator.clipboard.writeText) {
24428            navigator.clipboard.writeText(value).then(flashSuccess, function () {
24429              fallbackCopy(value, flashSuccess, flashFail);
24430            });
24431          } else {
24432            fallbackCopy(value, flashSuccess, flashFail);
24433          }
24434        });
24435      });
24436      function fallbackCopy(text, onSuccess, onFail) {
24437        try {
24438          var ta = document.createElement('textarea');
24439          ta.value = text;
24440          ta.style.position = 'fixed';
24441          ta.style.top = '-9999px';
24442          ta.style.left = '-9999px';
24443          document.body.appendChild(ta);
24444          ta.focus();
24445          ta.select();
24446          var ok = document.execCommand('copy');
24447          document.body.removeChild(ta);
24448          if (ok) { onSuccess(); } else { onFail(); }
24449        } catch (e) { onFail(); }
24450      }
24451
24452      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24453        btn.addEventListener('click', function () {
24454          var folder = btn.getAttribute('data-folder') || '';
24455          if (!folder) return;
24456          var orig = btn.textContent;
24457          fetch('/open-path?path=' + encodeURIComponent(folder))
24458            .then(function (r) { return r.json(); })
24459            .then(function (d) {
24460              if (d && d.server_mode_disabled) {
24461                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24462              } else if (d && d.ok) {
24463                btn.textContent = 'Opened!';
24464                setTimeout(function () { btn.textContent = orig; }, 1800);
24465              }
24466            })
24467            .catch(function () {
24468              btn.textContent = 'Failed';
24469              setTimeout(function () { btn.textContent = orig; }, 2000);
24470            });
24471        });
24472      });
24473
24474      loadSavedTheme();
24475
24476      // ── Compact number formatting for stat chips ──────────────────────────
24477      (function(){
24478        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();}
24479        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24480          var raw=parseInt(chip.getAttribute('data-raw'),10);
24481          if(isNaN(raw))return;
24482          var valEl=chip.querySelector('.stat-chip-val');
24483          if(valEl)valEl.textContent=fmt(raw);
24484          var exactEl=chip.querySelector('.stat-chip-exact');
24485          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24486        });
24487        // Code density chip
24488        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24489          var code=parseInt(chip.getAttribute('data-code'),10);
24490          var phys=parseInt(chip.getAttribute('data-physical'),10);
24491          if(isNaN(code)||isNaN(phys)||phys===0)return;
24492          var pct=(code/phys*100).toFixed(1)+'%';
24493          var valEl=chip.querySelector('.stat-chip-val');
24494          if(valEl)valEl.textContent=pct;
24495        });
24496        // Populate author handle from data-author attribute
24497        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24498          var author=chip.getAttribute('data-author');
24499          var el=chip.querySelector('.author-handle');
24500          if(el)el.textContent='/'+author.replace(/\s+/g,'');
24501        });
24502        // Click-to-copy on run-id-chip elements
24503        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24504          chip.addEventListener('click',function(){
24505            var val=chip.getAttribute('data-copy');
24506            if(!val)return;
24507            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24508            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);}
24509            chip.classList.add('chip-copied-flash');
24510            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24511          });
24512        });
24513        // Format delta card values with data-raw using comma-separated full numbers
24514        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24515          var raw=parseInt(card.getAttribute('data-raw'),10);
24516          if(isNaN(raw))return;
24517          var valEl=card.querySelector('.delta-card-val');
24518          if(valEl)valEl.textContent=raw.toLocaleString();
24519        });
24520        // Format code-before / code-now numbers in the compare banner stats line
24521        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24522          var raw=parseInt(el.getAttribute('data-raw'),10);
24523          if(!isNaN(raw))el.textContent=raw.toLocaleString();
24524        });
24525      })();
24526
24527      // ── Shared tooltip for all result-page charts ─────────────────────────
24528      var rTT=(function(){
24529        var el=document.getElementById('r-tt');
24530        if(!el)return{s:function(){},h:function(){},m:function(){}};
24531        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24532        function hide(){el.style.display='none';}
24533        function move(e){
24534          var x=e.clientX+16,y=e.clientY-12;
24535          var r=el.getBoundingClientRect();
24536          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24537          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24538          el.style.left=x+'px';el.style.top=y+'px';
24539        }
24540        return{s:show,h:hide,m:move};
24541      })();
24542      window.rTT=rTT;
24543
24544      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24545      (function(){
24546        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24547        document.addEventListener('mouseover',function(e){
24548          var t=e.target;
24549          while(t&&t.getAttribute){
24550            var l=t.getAttribute('data-ttl');
24551            if(l!==null){
24552              var v=t.getAttribute('data-ttv')||'';
24553              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24554              return;
24555            }
24556            t=t.parentNode;
24557          }
24558        });
24559        document.addEventListener('mouseout',function(e){
24560          var t=e.target;
24561          while(t&&t.getAttribute){
24562            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24563            t=t.parentNode;
24564          }
24565        });
24566        document.addEventListener('mousemove',function(e){
24567          var el=document.getElementById('r-tt');
24568          if(el&&el.style.display!=='none')rTT.m(e);
24569        });
24570        window.addEventListener('blur',function(){rTT.h();});
24571        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24572      })();
24573
24574      // ── Language overview charts ───────────────────────────────────────────
24575      (function(){
24576        var D={{ lang_chart_json|safe }};
24577        if(!D||!D.length)return;
24578        var el=document.getElementById('result-lang-charts');
24579        if(!el)return;
24580        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24581        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24582        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24583        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();}
24584        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24585        function px(n){return Math.round(n);}
24586        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+'"';}
24587        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24588        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24589        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24590        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;}
24591        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24592
24593        // Donut chart — height matches the stacked-bar chart so both panels align
24594        var rHb_d=28;
24595        var DH=Math.max(220,D.length*rHb_d+32);
24596        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24597        var legX=208,DW=395;
24598        var legCount=D.length;
24599        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24600        var legYStart=Math.round((DH-legCount*legSpacing)/2);
24601        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">';
24602        // One shared transition on every donut element so slices, leader lines,
24603        // outside labels, % labels and the legend all animate together as a single
24604        // picture when a language is hovered. Slices scale from the donut centre.
24605        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>';
24606        if(D.length===1){
24607          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24608          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+'"/>';
24609        } else {
24610          var smalls=[];
24611          var ang=-Math.PI/2;
24612          D.forEach(function(d,i){
24613            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24614            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24615            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24616            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24617            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24618            var pct=Math.round(d.code/tot*100);
24619            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"/>';
24620            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]});}
24621            ang+=sw;
24622          });
24623          // Small slices (<5%) get outside labels positioned near each slice's own
24624          // angular position (a slice on the left gets its label/leader on the left),
24625          // then nudged apart horizontally so text never overlaps. Leader lines point
24626          // from each slice to its label. Horizontal text keeps long names legible;
24627          // the whole SVG scales up in Full View so these stay readable there too.
24628          if(smalls.length){
24629            smalls.sort(function(a,b){return a.mAng-b.mAng;});
24630            var sPad=6,sRowY=11;
24631            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)));});
24632            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;}
24633            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24634            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24635            smalls.forEach(function(sm){
24636              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24637              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;"/>';
24638              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>';
24639            });
24640          }
24641        }
24642        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24643        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24644        D.forEach(function(d,i){
24645          var ly=legYStart+i*legSpacing;
24646          var pctL=Math.round(d.code/tot*100);
24647          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24648          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24649          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24650          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24651          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24652          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24653          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>';
24654          ds+='</g>';
24655        });
24656        ds+='</svg>';
24657
24658        // Horizontal stacked-bar chart — fills container width
24659        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24660        var LW=108,BW=260,svgW=LW+BW+68;
24661        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24662        var barBH=Math.min(32,Math.round(barRhb*0.7));
24663        var SH=DH;
24664        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24665        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">';
24666        D.forEach(function(d,i){
24667          var y=barTopPad+i*barRhb,x=LW;
24668          var phys=d.physical||d.code+d.comments+d.blanks;
24669          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24670          var lmid=y+barBH/2+4;
24671          // Combined breakdown shown when hovering the row, the language name, or the
24672          // total at the bar end (\n becomes a line break in the tooltip).
24673          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24674          bs+='<g class="lang-bar-row">';
24675          // Hit area ends just past the total label so empty space to the right of the
24676          // bar does not trigger the tooltip — only the name, bar and total are hot.
24677          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24678          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24679          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>';
24680          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;}
24681          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;}
24682          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>';}
24683          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>';
24684          bs+='</g>';
24685        });
24686        var ly=SH-14;
24687        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24688        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24689        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24690        var totAll=totC+totCm+totBl||1;
24691        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24692        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24693        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24694        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24695        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24696        bs+='<g data-kind="code" style="cursor:pointer;">'
24697          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24698          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24699          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24700          +'</g>';
24701        bs+='<g data-kind="comment" style="cursor:pointer;">'
24702          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24703          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24704          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24705          +'</g>';
24706        bs+='<g data-kind="blank" style="cursor:pointer;">'
24707          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24708          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24709          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24710          +'</g>';
24711        bs+='</svg>';
24712        el.innerHTML='<div class="r-lang-overview">'+
24713          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24714          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24715        '</div>';
24716        function wireDonutLegend(svg){
24717          if(!svg)return;
24718          // Every donut element carries data-lang: slices (path/circle), leader lines,
24719          // outside labels + % labels (text) and legend rows (g). Hovering any one of
24720          // them emphasises that language across all of them and fades the rest, so the
24721          // slice, its leader line, its label and its legend row move as one picture.
24722          var items=svg.querySelectorAll('[data-lang]');
24723          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24724            var tag=el.tagName.toLowerCase();
24725            if(tag==='path'||tag==='circle'){
24726              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)';}
24727              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24728              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24729            }else if(tag==='line'){
24730              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24731              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24732              else{el.style.opacity='';el.style.strokeWidth='';}
24733            }else if(tag==='text'){
24734              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24735              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24736              else{el.style.opacity='';el.style.fontWeight='';}
24737            }else{ // legend group
24738              if(st===1){el.style.opacity='1';}
24739              else if(st===-1){el.style.opacity='0.4';}
24740              else{el.style.opacity='';}
24741            }
24742          }
24743          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24744          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24745          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();});
24746          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();});
24747          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24748        }
24749        function wireMixLegend(svg){
24750          if(!svg)return;
24751          var legGs=svg.querySelectorAll('g[data-kind]');
24752          var allRects=svg.querySelectorAll('rect[data-kind]');
24753          if(!legGs.length)return;
24754          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';}}
24755          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='';}}
24756          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]);}
24757        }
24758        wireDonutLegend(el.querySelector('svg'));
24759        wireMixLegend(el.querySelectorAll('svg')[1]);
24760
24761        // ── Language breakdown Full View expand ─────────────────────────────────
24762        var langOvBtn=document.getElementById('result-lang-overview-expand');
24763        if(langOvBtn){langOvBtn.addEventListener('click',function(){
24764          var src=document.getElementById('result-lang-charts');
24765          if(!src)return;
24766          var overlay=document.createElement('div');
24767          overlay.className='r-chart-modal-overlay';
24768          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>';
24769          document.body.appendChild(overlay);
24770          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24771          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24772          var wrap=document.getElementById('result-lang-overview-modal-wrap');
24773          if(wrap){
24774            wrap.innerHTML=src.innerHTML;
24775            var svgs=wrap.querySelectorAll('svg');
24776            for(var i=0;i<svgs.length;i++){
24777              svgs[i].removeAttribute('width');
24778              svgs[i].removeAttribute('height');
24779              svgs[i].style.cssText='display:block;width:100%;height:auto;';
24780            }
24781            var ov=wrap.querySelector('.r-lang-overview');
24782            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24783            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24784            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24785            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24786            wireDonutLegend(wrap.querySelector('svg'));
24787            wireMixLegend(wrap.querySelectorAll('svg')[1]);
24788            requestAnimationFrame(function(){
24789              var ss=wrap.querySelectorAll('svg');
24790              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%;';}}
24791            });
24792          }
24793        });}
24794      })();
24795
24796      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24797      (function(){
24798        var LANG_D={{ lang_chart_json|safe }};
24799        var SCAT_D={{ scatter_chart_json|safe }};
24800        var SEM_D={{ semantic_chart_json|safe }};
24801        var SUB_D={{ submodule_chart_json|safe }};
24802        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24803        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24804        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();}
24805        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24806        function px(n){return Math.round(n);}
24807        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+'"';}
24808        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24809        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24810        // rather than disappear; the SVG scales up in Full View).
24811        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;}
24812
24813        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24814        function renderCompositionInEl(el,mode,shOvr){
24815          if(!el||!LANG_D||!LANG_D.length)return;
24816          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24817          var LW=110,SH=shOvr||300;
24818          var svgW=Math.max(320,el.offsetWidth||480);
24819          var BW=Math.max(120,svgW-LW-80);
24820          var legendH=24,topPad=4;
24821          var n=LANG_D.length||1;
24822          var rowTotal=Math.floor((SH-legendH-topPad)/n);
24823          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24824          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">';
24825          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24826          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24827          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24828          var totAll2=totC2+totCm2+totBl2||1;
24829          if(mode==='pct'){
24830            LANG_D.forEach(function(d,i){
24831              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24832              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24833              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24834              var lmid=y+Math.floor(bH/2)+4;
24835              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24836              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>';
24837              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;}
24838              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;}
24839              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>';}
24840              var pct=Math.round((d.code||0)/tot2*100);
24841              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>';
24842            });
24843          } else {
24844            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24845            LANG_D.forEach(function(d,i){
24846              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24847              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24848              var lmid=y+Math.floor(bH/2)+4;
24849              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));
24850              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>';
24851              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;}
24852              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;}
24853              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>';}
24854              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>';
24855            });
24856          }
24857          var ly=SH-legendH+4;
24858          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24859          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24860          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24861          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24862          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24863          s+='<g data-kind="code" style="cursor:pointer;">'
24864            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24865            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24866            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24867            +'</g>';
24868          s+='<g data-kind="comment" style="cursor:pointer;">'
24869            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24870            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24871            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24872            +'</g>';
24873          s+='<g data-kind="blank" style="cursor:pointer;">'
24874            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24875            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24876            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24877            +'</g>';
24878          s+='</svg>';
24879          el.innerHTML=s;
24880          wireMixLegendEl(el);
24881        }
24882        function wireMixLegendEl(container){
24883          var svg=container&&container.querySelector('svg');
24884          if(!svg)return;
24885          var legGs=svg.querySelectorAll('g[data-kind]');
24886          var allRects=svg.querySelectorAll('rect[data-kind]');
24887          if(!legGs.length)return;
24888          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';}}
24889          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='';}}
24890          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]);}
24891        }
24892        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24893        renderComposition('abs');
24894        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24895          btn.addEventListener('click',function(){
24896            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24897            btn.classList.add('active');
24898            renderComposition(btn.getAttribute('data-rcomp'));
24899          });
24900        });
24901
24902        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24903        function wireScatterLegend(container){
24904          var svg=container&&container.querySelector('svg');
24905          if(!svg)return;
24906          var legGs=svg.querySelectorAll('g[data-lang]');
24907          var circs=svg.querySelectorAll('circle[data-lang]');
24908          var labs=svg.querySelectorAll('text[data-lang]');
24909          if(!legGs.length)return;
24910          // Raise an element to the top of its parent so the hovered bubble and its
24911          // name/number labels sit above overlapping neighbours (clustered bubbles
24912          // otherwise bury the one you are trying to read).
24913          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24914          function hl(lang){
24915            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';}}
24916            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';}}
24917            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24918          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='';}}
24919          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]);}
24920        }
24921        function renderScatterInEl(el,hOvr){
24922          if(!el||!SCAT_D||!SCAT_D.length)return;
24923          var n=SCAT_D.length;
24924          var H=hOvr||300,PL=52,PB=36,PT=44;
24925          var W=Math.max(320,el.offsetWidth||480);
24926          var cH=H-PT-PB;
24927          // Legend: max 2 columns, fills vertical space. The compact card shows the
24928          // top languages by code lines plus a "+N more" row linking to Full View;
24929          // Full View (hOvr set) shows every language across up to 2 tall columns.
24930          var compact=!hOvr;
24931          var availH=Math.max(120,H-24);
24932          var rowsFit=Math.max(2,Math.floor(availH/18));
24933          var legTrunc=compact&&(n>2*rowsFit);
24934          var legShown=legTrunc?(2*rowsFit-1):n;
24935          var legTotal=legTrunc?(2*rowsFit):n;
24936          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24937          var legPerCol=Math.ceil(legTotal/legCols);
24938          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24939          var legColW=hOvr?144:130;
24940          var LG=26;
24941          var legW=legCols*legColW;
24942          var cW=W-PL-LG-legW;
24943          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24944          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24945          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24946          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24947          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24948          var logMaxF=Math.log1p(maxF);
24949          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">';
24950          // Smooth the legend-hover fade so bubbles + labels animate together.
24951          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24952          // Y grid lines (linear)
24953          [0,0.25,0.5,0.75,1].forEach(function(t){
24954            var y=PT+cH*(1-t);
24955            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24956            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>';
24957          });
24958          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24959          [0,0.25,0.5,0.75,1].forEach(function(t){
24960            var x=PL+cW*t;
24961            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24962            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24963            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>';
24964          });
24965          // Full View (hOvr set) has the vertical room to show the per-bubble value
24966          // line; the compact card shows only the language label to avoid the
24967          // overlapping-label clutter seen when bubbles cluster together.
24968          var showVal=!!hOvr;
24969          SCAT_D.forEach(function(d,i){
24970            // X uses log1p so outlier languages (many files) don't push others to the far left
24971            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24972            var cy2=PT+cH-d.code/maxC*cH;
24973            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24974            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"/>';
24975            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24976            if(showVal){
24977              var ty2=Math.max(24,px(cy2)-px(r)-3);
24978              var ty1=Math.max(12,ty2-14);
24979              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>';
24980              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>';
24981            }else{
24982              var ly2=Math.max(12,px(cy2)-px(r)-3);
24983              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>';
24984            }
24985          });
24986          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>';
24987          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>';
24988          // Legend (right side — top languages, max 2 columns, fills height)
24989          var legX=PL+cW+LG;
24990          var legBlockH=legPerCol*legRowH;
24991          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24992          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24993          for(var lk=0;lk<legShown;lk++){
24994            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24995            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24996            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;">';
24997            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24998            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24999            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>';
25000            s+='</g>';
25001          }
25002          if(legTrunc){
25003            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25004            s+='<g data-more="1" style="cursor:pointer;">';
25005            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25006            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25007            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>';
25008            s+='</g>';
25009          }
25010          s+='</svg>';
25011          el.innerHTML=s;
25012          wireScatterLegend(el);
25013          var moreEl=el.querySelector('g[data-more]');
25014          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25015        }
25016        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25017
25018        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25019        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25020        // the old vertical column layout on wide containers.
25021        function renderSemanticInEl(el,key,sh){
25022          if(!el||!SEM_D||!SEM_D.length)return;
25023          var n2=SEM_D.length||1;
25024          var LW=112,SH=sh||Math.max(180,n2*28+26);
25025          var svgW=Math.max(320,el.offsetWidth||480);
25026          var BW=Math.max(120,svgW-LW-80);
25027          var topPad=4,botPad=14;
25028          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25029          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25030          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25031          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">';
25032          SEM_D.forEach(function(d,i){
25033            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25034            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>';
25035            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"/>';
25036            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>';
25037          });
25038          s+='</svg>';
25039          el.innerHTML=s;
25040        }
25041        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25042        var semSel=document.getElementById('r-semantic-metric');
25043        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25044        var semExpand=document.getElementById('r-semantic-expand');
25045        if(semExpand){
25046          semExpand.addEventListener('click',function(){
25047            var key=semSel?semSel.value:'functions';
25048            var n=SEM_D.length||1;
25049            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25050            var modalH=Math.min(Math.max(360,n*38+60),maxH);
25051            var overlay=document.createElement('div');
25052            overlay.className='r-chart-modal-overlay';
25053            var optHtml=
25054              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25055              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25056              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25057              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25058              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25059            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>';
25060            document.body.appendChild(overlay);
25061            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25062            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25063            var modalEl=document.getElementById('r-sem-modal-chart');
25064            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25065            var modalSel=document.getElementById('r-sem-modal-metric');
25066            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25067          });
25068        }
25069
25070        // ── Expand buttons: re-render charts at large size inside modal ──────────
25071        (function(){
25072          function makeExpandModal(title,mH,subtitle,ctrlHtml){
25073            var overlay=document.createElement('div');
25074            overlay.className='r-chart-modal-overlay';
25075            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25076            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25077            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>';
25078            document.body.appendChild(overlay);
25079            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25080            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25081            return overlay.querySelector('.r-expand-modal-chart');
25082          }
25083          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25084          var compExpandBtn=document.getElementById('r-composition-expand');
25085          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25086            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25087            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25088            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25089              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25090            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25091            if(wrap){
25092              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25093              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25094                btn.addEventListener('click',function(){
25095                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25096                  btn.classList.add('active');
25097                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25098                });
25099              });
25100            }
25101          });}
25102          var scatExpandBtn=document.getElementById('r-scatter-expand');
25103          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25104            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25105            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25106          });}
25107          var densExpandBtn=document.getElementById('r-density-expand');
25108          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25109            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25110            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25111            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25112          });}
25113          var avgExpandBtn=document.getElementById('r-avglines-expand');
25114          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25115            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25116            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25117            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25118          });}
25119          var subExpandBtn=document.getElementById('r-submodule-expand');
25120          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25121            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25122            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25123            var metCtrl=
25124              '<select class="r-chart-select" id="r-sub-modal-metric">'
25125              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25126              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25127              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25128              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25129              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25130              +'</select>';
25131            var sortCtrl=
25132              '<select class="r-chart-select" id="r-sub-modal-sort">'
25133              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25134              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25135              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25136              +'</select>';
25137            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25138            if(wrap){
25139              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25140              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25141              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25142              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25143              if(mSub)mSub.addEventListener('change',reRenderSub);
25144              if(mSort)mSort.addEventListener('change',reRenderSub);
25145            }
25146          });}
25147        })();
25148
25149        // ── Comment Density: comments / (code + comments) per language ───────────
25150        function renderDensityInEl(el,shOvr){
25151          if(!el||!LANG_D||!LANG_D.length)return;
25152          var n=LANG_D.length||1;
25153          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25154          var svgW=Math.max(320,el.offsetWidth||480);
25155          var BW=Math.max(120,svgW-LW-80);
25156          var topPad=4,botPad=26;
25157          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25158          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25159          var densities=LANG_D.map(function(d){
25160            var sig=(d.code||0)+(d.comments||0);
25161            return sig>0?(d.comments||0)/sig:0;
25162          });
25163          var maxDen=Math.max.apply(null,densities)||1;
25164          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">';
25165          LANG_D.forEach(function(d,i){
25166            var den=densities[i],bw=den/maxDen*BW;
25167            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25168            var pct=Math.round(den*100);
25169            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>';
25170            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"/>';
25171            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25172            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>';
25173          });
25174          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>';
25175          s+='</svg>';
25176          el.innerHTML=s;
25177        }
25178        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25179        renderDensity();
25180
25181        // ── Avg Lines per File: code / files per language ─────────────────────
25182        function renderAvgLinesInEl(el,shOvr){
25183          if(!el||!LANG_D||!LANG_D.length)return;
25184          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25185          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25186          var n=data.length||1;
25187          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25188          var svgW=Math.max(320,el.offsetWidth||480);
25189          var BW=Math.max(120,svgW-LW-80);
25190          var topPad=4,botPad=26;
25191          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25192          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25193          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25194          var maxAvg=Math.max.apply(null,avgs)||1;
25195          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">';
25196          data.forEach(function(d,i){
25197            var avg=avgs[i],bw=avg/maxAvg*BW;
25198            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25199            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>';
25200            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"/>';
25201            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25202            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>';
25203          });
25204          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>';
25205          s+='</svg>';
25206          el.innerHTML=s;
25207        }
25208        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25209        renderAvgLines();
25210
25211        // ── Repository Overview: overall row + per-submodule rows ────────────
25212        function renderSubmoduleInEl(el,key,sort,shOvr){
25213          if(!el)return;
25214          var overall={
25215            name:'Overall',
25216            code:{{ code_lines }},
25217            comment:{{ comment_lines }},
25218            blank:{{ blank_lines }},
25219            physical:{{ physical_lines }},
25220            files:{{ files_analyzed }},
25221            isOverall:true
25222          };
25223          var subs=SUB_D.slice();
25224          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25225          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25226          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25227          var data=[overall].concat(subs);
25228          var sepH=subs.length>0?14:0;
25229          var naturalH=data.length*32+sepH+16;
25230          var SH=shOvr||Math.max(100,naturalH);
25231          var svgW=Math.max(320,el.offsetWidth||480);
25232          var LW=116,BW=Math.max(200,svgW-LW-54);
25233          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25234          var OVERALL_COL='#6b7280';
25235          var topPad=4,botPad=8;
25236          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25237          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25238          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">';
25239          var yOff=topPad;
25240          data.forEach(function(d,i){
25241            var v=d[key]||0,bw=v/maxV*BW;
25242            var y=yOff+Math.floor((rowSlot-bH)/2);
25243            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25244            var label=d.name||d.path||'?';
25245            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>';
25246            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25247            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25248            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>';
25249            yOff+=rowSlot;
25250            if(d.isOverall&&subs.length>0){
25251              yOff+=sepH;
25252            }
25253          });
25254          s+='</svg>';
25255          el.innerHTML=s;
25256        }
25257        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25258        var subSel=document.getElementById('r-sub-metric');
25259        var sortSel=document.getElementById('r-sub-sort');
25260        renderSubmodule('code','desc');
25261        if(subSel){
25262          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25263          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25264        }
25265
25266        // Equalise heights within each chart row: if one chart in a grid row is taller
25267        // than its neighbour, re-render the shorter one at the taller height so bars fill
25268        // the available vertical space instead of leaving a gap.
25269        function syncRowHeights(){
25270          var avgEl=document.getElementById('r-avglines-chart');
25271          var subEl=document.getElementById('r-submodule-chart');
25272          if(avgEl&&subEl){
25273            var avgSvg=avgEl.querySelector('svg');
25274            var subSvg=subEl.querySelector('svg');
25275            if(avgSvg&&subSvg){
25276              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25277              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25278              var key=subSel?subSel.value||'code':'code';
25279              var sort=sortSel?sortSel.value:'desc';
25280              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25281              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25282            }
25283          }
25284          var semEl=document.getElementById('r-semantic-chart');
25285          var denEl=document.getElementById('r-density-chart');
25286          if(semEl&&denEl){
25287            var semSvg=semEl.querySelector('svg');
25288            var denSvg=denEl.querySelector('svg');
25289            if(semSvg&&denSvg){
25290              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25291              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25292              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25293              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25294            }
25295          }
25296        }
25297        syncRowHeights();
25298
25299        // Re-render all SVG charts when the window is resized so bars fill the card.
25300        var _rResizeTimer;
25301        window.addEventListener('resize',function(){
25302          clearTimeout(_rResizeTimer);
25303          _rResizeTimer=setTimeout(function(){
25304            var rcompBtn=document.querySelector('[data-rcomp].active');
25305            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25306            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25307            if(semSel)renderSemantic(semSel.value||'functions');
25308            renderDensity();
25309            renderAvgLines();
25310            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25311            syncRowHeights();
25312          },120);
25313        });
25314      })();
25315
25316      (function randomizeWatermarks() {
25317        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25318        if (!wms.length) return;
25319        var placed = [];
25320        function tooClose(top, left) {
25321          for (var i = 0; i < placed.length; i++) {
25322            var dt = Math.abs(placed[i][0] - top);
25323            var dl = Math.abs(placed[i][1] - left);
25324            if (dt < 20 && dl < 18) return true;
25325          }
25326          return false;
25327        }
25328        function pick(leftBand) {
25329          for (var attempt = 0; attempt < 50; attempt++) {
25330            var top = Math.random() * 85 + 5;
25331            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25332            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25333          }
25334          var top = Math.random() * 85 + 5;
25335          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25336          placed.push([top, left]);
25337          return [top, left];
25338        }
25339        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25340        var half = Math.floor(wms.length / 2);
25341        wms.forEach(function (img, i) {
25342          var pos = pick(i < half);
25343          var size = Math.floor(Math.random() * 100 + 160);
25344          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25345          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25346          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;
25347        });
25348      })();
25349
25350      (function spawnCodeParticles() {
25351        var container = document.getElementById('code-particles');
25352        if (!container) return;
25353        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'];
25354        for (var i = 0; i < 38; i++) {
25355          (function(idx) {
25356            var el = document.createElement('span');
25357            el.className = 'code-particle';
25358            el.textContent = snippets[idx % snippets.length];
25359            var left = Math.random() * 94 + 2;
25360            var top = Math.random() * 88 + 6;
25361            var dur = (Math.random() * 10 + 9).toFixed(1);
25362            var delay = (Math.random() * 18).toFixed(1);
25363            var rot = (Math.random() * 26 - 13).toFixed(1);
25364            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25365            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';
25366            container.appendChild(el);
25367          })(i);
25368        }
25369      })();
25370
25371      {% if pdf_generating %}
25372      // Poll for PDF readiness and swap the disabled button to a live link once done.
25373      (function() {
25374        var openBtn = document.getElementById('pdf-open-btn');
25375        var dlBtn = document.getElementById('pdf-download-btn');
25376        function checkPdf() {
25377          fetch('/api/runs/{{ run_id }}/pdf-status')
25378            .then(function(r) { return r.json(); })
25379            .then(function(d) {
25380              if (d.ready) {
25381                if (openBtn) {
25382                  var a = document.createElement('a');
25383                  a.className = 'button';
25384                  a.id = 'pdf-open-btn';
25385                  a.href = '/runs/pdf/{{ run_id }}';
25386                  a.target = '_blank';
25387                  a.rel = 'noopener';
25388                  a.textContent = 'Open PDF';
25389                  openBtn.replaceWith(a);
25390                }
25391                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25392              } else {
25393                setTimeout(checkPdf, 3000);
25394              }
25395            })
25396            .catch(function() { setTimeout(checkPdf, 5000); });
25397        }
25398        setTimeout(checkPdf, 3000);
25399      })();
25400      {% endif %}
25401
25402    })();
25403  </script>
25404  <script nonce="{{ csp_nonce }}">
25405  (function(){
25406    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'}];
25407    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);});}
25408    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25409    function init(){
25410      var btn=document.getElementById('settings-btn');if(!btn)return;
25411      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25412      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>';
25413      document.body.appendChild(m);
25414      var g=document.getElementById('scheme-grid');
25415      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);});
25416      var cl=document.getElementById('settings-close');
25417      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);});})();
25418      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');});
25419      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25420      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25421    }
25422    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25423  }());
25424  </script>
25425  <footer class="site-footer">
25426    local code analysis - metrics, history and reports
25427    &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>
25428    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25429    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25430    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25431    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25432  </footer>
25433  {% if confluence_configured %}
25434  <script nonce="{{ csp_nonce }}">
25435  (function() {
25436    var postBtn = document.getElementById('postConfluenceBtn');
25437    var copyBtn = document.getElementById('copyWikiBtn');
25438    var modal   = document.getElementById('confluenceModal');
25439    if (!postBtn || !modal) return;
25440
25441    postBtn.addEventListener('click', function() {
25442      document.getElementById('confStatus').style.display = 'none';
25443      modal.style.display = 'flex';
25444    });
25445    document.getElementById('confCancelBtn').addEventListener('click', function() {
25446      modal.style.display = 'none';
25447    });
25448    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25449
25450    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25451      var btn = this;
25452      btn.disabled = true;
25453      var status = document.getElementById('confStatus');
25454      status.style.display = 'block';
25455      status.style.background = '#dbeafe';
25456      status.style.color = '#1e40af';
25457      status.textContent = 'Posting to Confluence\u2026';
25458      var resp = await fetch('/api/confluence/post', {
25459        method: 'POST',
25460        headers: { 'Content-Type': 'application/json' },
25461        body: JSON.stringify({
25462          run_id: '{{ run_id }}',
25463          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25464          report_url: document.getElementById('confReportUrl').value.trim() || null
25465        })
25466      });
25467      var data = await resp.json();
25468      if (data.ok) {
25469        status.style.background = '#dcfce7'; status.style.color = '#166534';
25470        status.textContent = 'Posted! Page ID: ' + data.page_id;
25471      } else {
25472        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25473        status.textContent = 'Error: ' + (data.error || 'Unknown error');
25474      }
25475      btn.disabled = false;
25476    });
25477
25478    if (copyBtn) {
25479      copyBtn.addEventListener('click', async function() {
25480        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25481        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25482        var text = await resp.text();
25483        try {
25484          await navigator.clipboard.writeText(text);
25485          var orig = copyBtn.textContent;
25486          copyBtn.textContent = 'Copied!';
25487          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25488        } catch(e) {
25489          alert('Clipboard write failed \u2014 check browser permissions.');
25490        }
25491      });
25492    }
25493  })();
25494  </script>
25495  {% endif %}
25496  <script nonce="{{ csp_nonce }}">
25497  (function() {
25498    var deleteBtn = document.getElementById('delete-run-btn');
25499    var modal     = document.getElementById('delete-run-modal');
25500    var cancelBtn = document.getElementById('delete-run-cancel');
25501    var confirmBtn= document.getElementById('delete-run-confirm');
25502    if (!deleteBtn || !modal) return;
25503    deleteBtn.addEventListener('click', function() {
25504      document.getElementById('delete-run-status').style.display = 'none';
25505      modal.style.display = 'flex';
25506    });
25507    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25508    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25509    confirmBtn.addEventListener('click', async function() {
25510      confirmBtn.disabled = true;
25511      cancelBtn.disabled = true;
25512      var status = document.getElementById('delete-run-status');
25513      status.style.display = 'block';
25514      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25515      status.textContent = 'Deleting\u2026';
25516      try {
25517        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25518        if (resp.status === 204 || resp.ok) {
25519          status.style.background = '#dcfce7'; status.style.color = '#166534';
25520          status.textContent = 'Deleted. Redirecting\u2026';
25521          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25522        } else {
25523          var d = await resp.json().catch(function(){return {};});
25524          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25525          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25526          confirmBtn.disabled = false;
25527          cancelBtn.disabled = false;
25528        }
25529      } catch (e) {
25530        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25531        status.textContent = 'Network error: ' + String(e);
25532        confirmBtn.disabled = false;
25533        cancelBtn.disabled = false;
25534      }
25535    });
25536  })();
25537  </script>
25538  <script nonce="{{ csp_nonce }}">(function(){
25539    var bundleBtn = document.getElementById('download-bundle-btn');
25540    if (bundleBtn) {
25541      bundleBtn.addEventListener('click', function() {
25542        bundleBtn.disabled = true;
25543        var orig = bundleBtn.textContent;
25544        bundleBtn.textContent = 'Preparing\u2026';
25545        fetch('/api/runs/{{ run_id }}/bundle')
25546          .then(function(r) {
25547            if (!r.ok) throw new Error('HTTP ' + r.status);
25548            return r.blob();
25549          })
25550          .then(function(blob) {
25551            var url = URL.createObjectURL(blob);
25552            var a = document.createElement('a');
25553            a.href = url;
25554            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25555            document.body.appendChild(a);
25556            a.click();
25557            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25558            bundleBtn.disabled = false;
25559            bundleBtn.textContent = orig;
25560          })
25561          .catch(function(e) {
25562            bundleBtn.disabled = false;
25563            bundleBtn.textContent = orig;
25564            alert('Bundle download failed: ' + String(e));
25565          });
25566      });
25567    }
25568  })();</script>
25569  <script nonce="{{ csp_nonce }}">(function(){
25570    var dot=document.getElementById('status-dot');
25571    var pingEl=document.getElementById('server-ping-ms');
25572    var tipEl=document.getElementById('server-tip-ping');
25573    var fm=document.getElementById('footer-mode');
25574    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)';}}
25575    function doPing(){
25576      var t0=performance.now();
25577      fetch('/healthz',{cache:'no-store'})
25578        .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);})
25579        .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)';}});
25580    }
25581    doPing();
25582    setInterval(doPing,5000);
25583    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');}
25584  })();</script>
25585  <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>
25586  {% if let Some(banner) = report_header_footer %}
25587  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25588  {% endif %}
25589</body>
25590</html>
25591"##,
25592    ext = "html"
25593)]
25594// Template structs need many bool fields to pass Askama rendering flags.
25595#[allow(clippy::struct_excessive_bools)]
25596struct ResultTemplate {
25597    version: &'static str,
25598    report_title: String,
25599    project_path: String,
25600    output_dir: String,
25601    run_id: String,
25602    files_analyzed: u64,
25603    files_skipped: u64,
25604    physical_lines: u64,
25605    code_lines: u64,
25606    comment_lines: u64,
25607    blank_lines: u64,
25608    mixed_lines: u64,
25609    functions: u64,
25610    classes: u64,
25611    variables: u64,
25612    imports: u64,
25613    html_url: Option<String>,
25614    pdf_url: Option<String>,
25615    json_url: Option<String>,
25616    html_download_url: Option<String>,
25617    pdf_download_url: Option<String>,
25618    json_download_url: Option<String>,
25619    html_path: Option<String>,
25620    json_path: Option<String>,
25621    prev_run_id: Option<String>,
25622    prev_run_timestamp: Option<String>,
25623    prev_run_code_lines: Option<u64>,
25624    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
25625    prev_fa_str: String,
25626    prev_fs_str: String,
25627    prev_pl_str: String,
25628    prev_cl_str: String,
25629    prev_cml_str: String,
25630    prev_bl_str: String,
25631    // Signed change column for main metrics
25632    delta_fa_str: String,
25633    delta_fa_class: String,
25634    delta_fs_str: String,
25635    delta_fs_class: String,
25636    delta_pl_str: String,
25637    delta_pl_class: String,
25638    delta_cl_str: String,
25639    delta_cl_class: String,
25640    delta_cml_str: String,
25641    delta_cml_class: String,
25642    delta_bl_str: String,
25643    delta_bl_class: String,
25644    // delta vs previous scan
25645    delta_lines_added: Option<i64>,
25646    delta_lines_removed: Option<i64>,
25647    delta_lines_net_str: String,
25648    delta_lines_net_class: String,
25649    delta_files_added: Option<usize>,
25650    delta_files_removed: Option<usize>,
25651    delta_files_modified: Option<usize>,
25652    delta_files_unchanged: Option<usize>,
25653    delta_files_total: Option<usize>,
25654    delta_unmodified_lines: Option<u64>,
25655    // git context
25656    git_branch: Option<String>,
25657    git_branch_url: Option<String>,
25658    git_commit: Option<String>,
25659    git_commit_long: Option<String>,
25660    git_author: Option<String>,
25661    git_commit_url: Option<String>,
25662    // scan metadata for hero section
25663    scan_performed_by: String,
25664    scan_time_display: String,
25665    scan_time_utc_ms: i64,
25666    os_display: String,
25667    test_count: u64,
25668    // reserve "pad" card, revealed by JS only when the visible card count is odd
25669    test_assertion_count: u64,
25670    // history
25671    prev_scan_count: usize,
25672    current_scan_number: usize,
25673    // submodule breakdown (empty when not requested)
25674    submodule_rows: Vec<SubmoduleRow>,
25675    scan_config_url: String,
25676    lang_chart_json: String,
25677    // Askama reads these via proc-macro expansion; clippy can't trace through it.
25678    #[allow(dead_code)]
25679    scatter_chart_json: String,
25680    #[allow(dead_code)]
25681    semantic_chart_json: String,
25682    #[allow(dead_code)]
25683    submodule_chart_json: String,
25684    #[allow(dead_code)]
25685    has_submodule_data: bool,
25686    #[allow(dead_code)]
25687    has_semantic_data: bool,
25688    pdf_generating: bool,
25689    csp_nonce: String,
25690    /// Whether Confluence integration is configured — shows Post button when true.
25691    confluence_configured: bool,
25692    server_mode: bool,
25693    /// Header/footer identification banner, mirrored from the HTML/PDF report.
25694    report_header_footer: Option<String>,
25695    run_id_short: String,
25696    /// True when rendering a static offline file (index.html); hides server-only actions.
25697    #[allow(dead_code)]
25698    is_offline: bool,
25699    /// Total cyclomatic complexity score across all analyzed files.
25700    cyclomatic_complexity: u64,
25701    /// Logical SLOC (statement count) when available; None for unsupported languages.
25702    lsloc: Option<u64>,
25703    /// Unique Lines of Code across all analyzed files.
25704    uloc: u64,
25705    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
25706    dryness_pct_str: String,
25707    /// Number of duplicate file groups detected.
25708    duplicate_group_count: usize,
25709    /// Whether a COCOMO estimate is available to display.
25710    has_cocomo: bool,
25711    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
25712    cocomo_effort_str: String,
25713    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
25714    cocomo_duration_str: String,
25715    /// Pre-formatted average team size, e.g. "2.32".
25716    cocomo_staff_str: String,
25717    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
25718    cocomo_ksloc_str: String,
25719    /// COCOMO mode label shown in the card (e.g. "Organic").
25720    cocomo_mode_label: String,
25721    /// Tooltip text explaining the selected COCOMO mode.
25722    cocomo_mode_tooltip: String,
25723    /// Per-file complexity alert threshold. 0 = off (no highlighting).
25724    complexity_alert: u32,
25725    /// Whether any file has coverage data attached.
25726    has_coverage_data: bool,
25727    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
25728    cov_line_pct: String,
25729    /// Overall function coverage percentage string — empty if no data.
25730    cov_fn_pct: String,
25731    /// Overall branch coverage percentage string — empty if no branch data.
25732    cov_branch_pct: String,
25733    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
25734    cov_lines_summary: String,
25735}
25736
25737#[derive(Template)]
25738#[template(
25739    source = r##"
25740<!doctype html>
25741<html lang="en">
25742<head>
25743  <meta charset="utf-8">
25744  <meta name="viewport" content="width=device-width, initial-scale=1">
25745  <title>OxideSLOC | Analyzing…</title>
25746  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25747  <style nonce="{{ csp_nonce }}">
25748    :root {
25749      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25750      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25751      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25752      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25753    }
25754    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25755    *{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;}
25756    .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);}
25757    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25758    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25759    .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));}
25760    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25761    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25762    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25763    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25764    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25765    @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; } }
25766    .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;}
25767    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25768    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25769    .page-body{padding:32px 24px 36px;}
25770    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25771    .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;}
25772    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25773    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25774    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25775    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25776    .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;}
25777    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25778    .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;}
25779    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25780    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25781    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25782    .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;}
25783    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25784    .hidden{display:none!important;}
25785    .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;}
25786    .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;}
25787    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25788    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25789    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25790    .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);}
25791    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25792    .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;}
25793    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25794    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25795    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25796    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25797    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25798    .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;}
25799    @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));}}
25800    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25801    .site-footer a{color:var(--muted);}
25802    .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;}
25803    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25804    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25805    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25806  </style>
25807</head>
25808<body>
25809  <div class="background-watermarks" aria-hidden="true">
25810    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25811    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25812    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25813    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25814    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25815    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25816  </div>
25817  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25818  <nav class="top-nav">
25819    <div class="top-nav-inner">
25820      <a href="/" class="brand">
25821        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25822        <div class="brand-copy">
25823          <h1 class="brand-title">OxideSLOC</h1>
25824          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25825        </div>
25826      </a>
25827      <div class="nav-right">
25828        <a class="nav-pill" href="/">Home</a>
25829        <div class="nav-dropdown">
25830          <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>
25831          <div class="nav-dropdown-menu">
25832            <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>
25833          </div>
25834        </div>
25835        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25836        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25837        <div class="nav-dropdown">
25838          <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>
25839          <div class="nav-dropdown-menu">
25840            <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>
25841          </div>
25842        </div>
25843        <div class="server-status-wrap" id="server-status-wrap">
25844          <div class="nav-pill server-online-pill" id="server-status-pill">
25845            <span class="status-dot" id="status-dot"></span>
25846            <span id="server-status-label">Server</span>
25847            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25848          </div>
25849          <div class="server-status-tip">
25850            OxideSLOC is running — accessible on your network.
25851            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25852          </div>
25853        </div>
25854        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25855          <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>
25856        </button>
25857        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25858          <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>
25859          <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>
25860        </button>
25861      </div>
25862    </div>
25863  </nav>
25864  <div class="page-body">
25865    <div class="wait-panel">
25866      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25867      <h2 class="wait-title">Analyzing your project…</h2>
25868      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25869      <div class="path-block">{{ project_path }}</div>
25870      <div class="metrics-row">
25871        <div class="metric-card">
25872          <div class="metric-label">Elapsed</div>
25873          <div class="metric-value" id="elapsed">0s</div>
25874        </div>
25875        <div class="metric-card">
25876          <div class="metric-label">Phase</div>
25877          <div class="metric-value" id="phase">Starting</div>
25878        </div>
25879        <div class="metric-card hidden" id="files-card">
25880          <div class="metric-label">Files</div>
25881          <div class="metric-value" id="files-progress">0</div>
25882        </div>
25883      </div>
25884      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25885      <div class="warn-slow hidden" id="warn-slow">
25886        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.
25887      </div>
25888      <div class="err-panel hidden" id="err-panel">
25889        <strong>Analysis failed</strong>
25890        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25891      </div>
25892      <div class="actions hidden" id="actions">
25893        <a href="/scan" class="btn-primary">Try Again</a>
25894        <a href="/view-reports" class="btn-outline">View Reports</a>
25895      </div>
25896    </div>
25897  </div>
25898  <script nonce="{{ csp_nonce }}">
25899    (function() {
25900      var WAIT_ID = {{ wait_id_json|safe }};
25901      var startTime = Date.now();
25902      var pollInterval = 1500;
25903      var retries = 0;
25904      var maxRetries = 5;
25905      var warnShown = false;
25906
25907      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();}
25908
25909      function elapsed() {
25910        return Math.floor((Date.now() - startTime) / 1000);
25911      }
25912
25913      function updateElapsed() {
25914        var s = elapsed();
25915        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25916      }
25917
25918      function setPhase(txt) {
25919        document.getElementById('phase').textContent = txt;
25920      }
25921
25922      var elapsedTimer = setInterval(updateElapsed, 1000);
25923
25924      function poll() {
25925        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25926          .then(function(r) {
25927            if (!r.ok) throw new Error('HTTP ' + r.status);
25928            return r.json();
25929          })
25930          .then(function(data) {
25931            retries = 0;
25932            if (data.state === 'complete') {
25933              clearInterval(elapsedTimer);
25934              setPhase('Done');
25935              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25936            } else if (data.state === 'failed') {
25937              clearInterval(elapsedTimer);
25938              setPhase('Failed');
25939              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25940              document.getElementById('err-panel').classList.remove('hidden');
25941              document.getElementById('actions').classList.remove('hidden');
25942            } else {
25943              // still running
25944              var s = elapsed();
25945              if (s > 90 && !warnShown) {
25946                warnShown = true;
25947                document.getElementById('warn-slow').classList.remove('hidden');
25948              }
25949              setPhase(data.phase || 'Running');
25950              var fd = data.files_done || 0, ft = data.files_total || 0;
25951              if (ft > 0) {
25952                var card = document.getElementById('files-card');
25953                if (card) card.classList.remove('hidden');
25954                var fp = document.getElementById('files-progress');
25955                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25956              }
25957              setTimeout(poll, pollInterval);
25958            }
25959          })
25960          .catch(function(err) {
25961            retries++;
25962            if (retries >= maxRetries) {
25963              clearInterval(elapsedTimer);
25964              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25965              document.getElementById('err-panel').classList.remove('hidden');
25966              document.getElementById('actions').classList.remove('hidden');
25967            } else {
25968              // exponential back-off capped at 8s
25969              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25970            }
25971          });
25972      }
25973
25974      setTimeout(poll, pollInterval);
25975
25976      // If the browser restores this page from bfcache (Back after viewing results),
25977      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25978      window.addEventListener("pageshow", function(e) {
25979        if (e.persisted) { setTimeout(poll, 200); }
25980      });
25981    })();
25982  </script>
25983  <footer class="site-footer">
25984    local code analysis - metrics, history and reports
25985    &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>
25986    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25987    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25988    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25989    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25990  </footer>
25991  <script nonce="{{ csp_nonce }}">
25992    (function(){
25993      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25994      if(s==="dark")b.classList.add("dark-theme");
25995      var tt=document.getElementById("theme-toggle");
25996      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25997    })();
25998    (function spawnCodeParticles(){
25999      var c=document.getElementById('code-particles');if(!c)return;
26000      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'];
26001      for(var i=0;i<32;i++){(function(idx){
26002        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26003        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26004        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26005        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26006        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26007        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26008        c.appendChild(el);
26009      })(i);}
26010    })();
26011    (function randomizeWatermarks(){
26012      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26013      var placed=[];
26014      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;}
26015      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];}
26016      var half=Math.floor(wms.length/2);
26017      wms.forEach(function(img,i){
26018        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26019        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26020        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26021        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26022        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26023        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26024      });
26025    })();
26026  </script>
26027  <script nonce="{{ csp_nonce }}">
26028  (function(){
26029    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'}];
26030    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);});}
26031    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26032    function init(){
26033      var btn=document.getElementById('settings-btn');if(!btn)return;
26034      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26035      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>';
26036      document.body.appendChild(m);
26037      var g=document.getElementById('scheme-grid');
26038      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);});
26039      var cl=document.getElementById('settings-close');
26040      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);});})();
26041      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');});
26042      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26043      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26044    }
26045    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26046  }());
26047  </script>
26048  <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]';
26049  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;}
26050  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>
26051</body>
26052</html>
26053"##,
26054    ext = "html"
26055)]
26056struct ScanWaitTemplate {
26057    version: &'static str,
26058    wait_id_json: String,
26059    project_path: String,
26060    csp_nonce: String,
26061}
26062
26063#[derive(Template)]
26064#[template(
26065    source = r##"
26066<!doctype html>
26067<html lang="en">
26068<head>
26069  <meta charset="utf-8">
26070  <meta name="viewport" content="width=device-width, initial-scale=1">
26071  <title>OxideSLOC | Error</title>
26072  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26073  <style nonce="{{ csp_nonce }}">
26074    :root {
26075      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26076      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26077      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26078      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26079    }
26080    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26081    *{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;}
26082    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26083    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26084    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26085    .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);}
26086    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26087    .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));}
26088    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26089    .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;}
26090    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26091    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26092    @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; } }
26093    .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;}
26094    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26095    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26096    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26097    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26098    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26099    .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;}
26100    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26101    .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);}
26102    .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;}
26103    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26104    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26105    .settings-modal-body{padding:14px 16px 16px;}
26106    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26107    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26108    .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;}
26109    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26110    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26111    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26112    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26113    .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;}
26114    .tz-select:focus{border-color:var(--oxide);}
26115    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26116    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26117    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26118    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26119    .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;}
26120    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26121    .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);}
26122    .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;}
26123    .btn-secondary:hover{background:var(--line);}
26124    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26125    .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;}
26126    .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;}
26127    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26128    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26129    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26130    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26131    .bug-report-panel.open{display:flex;}
26132    .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;}
26133    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26134    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26135    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26136    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26137    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26138    .br-network-badge.online .br-net-dot{background:#2a6846;}
26139    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26140    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26141    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26142    .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;}
26143    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26144    .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;}
26145    .btn-sm:hover{background:var(--line);}
26146    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26147    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26148    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26149    .bug-report-hint a:hover{text-decoration:underline;}
26150    .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;}
26151    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26152    .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;}
26153    .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;}
26154    .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;}
26155    @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));}}
26156    .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;}
26157  </style>
26158</head>
26159<body>
26160  <div class="background-watermarks" aria-hidden="true">
26161    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26162    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26163    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26164    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26165    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26166    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26167  </div>
26168  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26169  <div class="top-nav">
26170    <div class="top-nav-inner">
26171      <a class="brand" href="/">
26172        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26173        <div class="brand-copy">
26174          <div class="brand-title">OxideSLOC</div>
26175          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26176        </div>
26177      </a>
26178      <div class="nav-right">
26179        <a class="nav-pill" href="/">Home</a>
26180        <div class="nav-dropdown">
26181          <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>
26182          <div class="nav-dropdown-menu">
26183            <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>
26184          </div>
26185        </div>
26186        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26187        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26188        <div class="nav-dropdown">
26189          <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>
26190          <div class="nav-dropdown-menu">
26191            <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>
26192          </div>
26193        </div>
26194        <div class="server-status-wrap" id="server-status-wrap">
26195          <div class="nav-pill server-online-pill" id="server-status-pill">
26196            <span class="status-dot" id="status-dot"></span>
26197            <span id="server-status-label">Server</span>
26198            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26199          </div>
26200          <div class="server-status-tip">
26201            OxideSLOC is running — accessible on your network.
26202            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26203          </div>
26204        </div>
26205        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26206          <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>
26207        </button>
26208        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26209          <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>
26210          <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>
26211        </button>
26212      </div>
26213    </div>
26214  </div>
26215
26216  <div class="page">
26217    <div class="panel">
26218      <h1>Error</h1>
26219      <div class="error-box" id="error-msg-text">{{ message }}</div>
26220      <div id="br-meta" hidden
26221        data-version="{{ version }}"
26222        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26223        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26224      <div class="actions">
26225        <a class="btn-primary" href="/scan">Back to setup</a>
26226        {% if let Some(report_url) = last_report_url %}
26227        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26228        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26229        {% else %}
26230        <a class="btn-secondary" href="/view-reports">View Reports</a>
26231        {% endif %}
26232      </div>
26233      <div class="bug-report-section" id="bug-report-section">
26234        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26235          <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>
26236          Generate Bug Report
26237          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26238        </button>
26239        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26240          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
26241          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
26242          <div class="bug-report-btns">
26243            <button type="button" class="btn-sm" id="bug-report-copy">
26244              <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>
26245              Copy to clipboard
26246            </button>
26247            <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;">
26248              <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>
26249              Open GitHub Issue
26250            </a>
26251            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26252              <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>
26253              Save as file
26254            </button>
26255          </div>
26256          <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>
26257          <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>
26258        </div>
26259      </div>
26260    </div>
26261  </div>
26262  <footer class="site-footer">
26263    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26264    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26265    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26266    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26267    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26268  </footer>
26269  <script nonce="{{ csp_nonce }}">(function(){
26270    var meta=document.getElementById('br-meta');
26271    var pre=document.getElementById('bug-report-pre');
26272    var copyBtn=document.getElementById('bug-report-copy');
26273    var trigger=document.getElementById('bug-report-trigger');
26274    var panel=document.getElementById('bug-report-panel');
26275    var networkBadge=document.getElementById('br-network-badge');
26276    var networkLabel=document.getElementById('br-network-label');
26277    var ghLink=document.getElementById('bug-report-github-link');
26278    var saveBtn=document.getElementById('bug-report-save');
26279    var hintOnline=document.getElementById('br-hint-online');
26280    var hintOffline=document.getElementById('br-hint-offline');
26281    if(!meta||!pre)return;
26282    var ver=meta.getAttribute('data-version')||'';
26283    var runId=meta.getAttribute('data-run-id')||'';
26284    var code=meta.getAttribute('data-error-code')||'';
26285    var msgEl=document.getElementById('error-msg-text');
26286    var msg=msgEl?msgEl.textContent.trim():'';
26287    function getBrowser(){
26288      var ua=navigator.userAgent;
26289      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26290      if(!m)return 'Unknown browser';
26291      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26292      return n+' '+m[2];
26293    }
26294    var lines=['oxide-sloc Bug Report','==============================',''];
26295    lines.push('App version:  v'+ver);
26296    if(code)lines.push('HTTP status:  '+code);
26297    if(runId)lines.push('Run ID:       '+runId);
26298    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
26299    lines.push('Timestamp:    '+new Date().toISOString());
26300    lines.push('Browser:      '+getBrowser());
26301    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
26302    lines.push('');
26303    lines.push('Error message:');
26304    lines.push(msg);
26305    lines.push('');
26306    lines.push('Steps to reproduce:');
26307    lines.push('  1. ');
26308    lines.push('');
26309    lines.push('Expected behavior:');
26310    lines.push('  ');
26311    pre.textContent=lines.join('\n');
26312    function applyNetwork(online){
26313      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26314      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26315      if(ghLink){
26316        if(online){
26317          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26318          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26319        }
26320        ghLink.style.display=online?'inline-flex':'none';
26321      }
26322      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26323      if(hintOnline)hintOnline.style.display=online?'block':'none';
26324      if(hintOffline)hintOffline.style.display=online?'none':'block';
26325    }
26326    applyNetwork(navigator.onLine);
26327    var probed=false;
26328    function probeNetwork(){
26329      if(probed)return;probed=true;
26330      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26331      var probeIdx=0;
26332      function tryNext(){
26333        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26334        var u=probeUrls[probeIdx++];
26335        var c2=new AbortController();
26336        var t2=setTimeout(function(){c2.abort();},4000);
26337        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26338          .then(function(){clearTimeout(t2);applyNetwork(true);})
26339          .catch(function(){clearTimeout(t2);tryNext();});
26340      }
26341      tryNext();
26342    }
26343    if(trigger&&panel){
26344      trigger.addEventListener('click',function(){
26345        var open=panel.classList.toggle('open');
26346        trigger.classList.toggle('open',open);
26347        trigger.setAttribute('aria-expanded',open?'true':'false');
26348        if(open)probeNetwork();
26349      });
26350    }
26351    if(copyBtn){
26352      copyBtn.addEventListener('click',function(){
26353        var txt=pre.textContent;
26354        if(navigator.clipboard&&navigator.clipboard.writeText){
26355          navigator.clipboard.writeText(txt).then(function(){
26356            copyBtn.textContent='\u2713 Copied!';
26357            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);
26358          });
26359        }else{
26360          var ta=document.createElement('textarea');
26361          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26362          document.body.appendChild(ta);ta.select();
26363          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26364          document.body.removeChild(ta);
26365        }
26366      });
26367    }
26368    if(saveBtn){
26369      saveBtn.addEventListener('click',function(){
26370        var txt=pre.textContent;
26371        var blob=new Blob([txt],{type:'text/plain'});
26372        var url=URL.createObjectURL(blob);
26373        var a=document.createElement('a');
26374        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26375        document.body.appendChild(a);a.click();
26376        document.body.removeChild(a);URL.revokeObjectURL(url);
26377      });
26378    }
26379  })();</script>
26380  <script nonce="{{ csp_nonce }}">
26381    (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");});})();
26382    (function spawnCodeParticles() {
26383      var container = document.getElementById('code-particles');
26384      if (!container) return;
26385      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'];
26386      for (var i = 0; i < 38; i++) {
26387        (function(idx) {
26388          var el = document.createElement('span');
26389          el.className = 'code-particle';
26390          el.textContent = snippets[idx % snippets.length];
26391          var left = Math.random() * 94 + 2;
26392          var top = Math.random() * 88 + 6;
26393          var dur = (Math.random() * 10 + 9).toFixed(1);
26394          var delay = (Math.random() * 18).toFixed(1);
26395          var rot = (Math.random() * 26 - 13).toFixed(1);
26396          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26397          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';
26398          container.appendChild(el);
26399        })(i);
26400      }
26401    })();
26402    (function randomizeWatermarks() {
26403      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26404      var placed = [];
26405      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; }
26406      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]; }
26407      var half = Math.floor(wms.length/2);
26408      wms.forEach(function(img, i) {
26409        var pos = pick(i < half);
26410        var w = Math.floor(Math.random()*60+80);
26411        var rot = (Math.random()*40-20).toFixed(1);
26412        var op = (Math.random()*0.08+0.05).toFixed(2);
26413        var animDur = (Math.random()*6+5).toFixed(1);
26414        var animDelay = (Math.random()*10).toFixed(1);
26415        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';
26416      });
26417    })();
26418  </script>
26419  <script nonce="{{ csp_nonce }}">
26420  (function(){
26421    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'}];
26422    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);});}
26423    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26424    function init(){
26425      var btn=document.getElementById('settings-btn');if(!btn)return;
26426      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26427      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>';
26428      document.body.appendChild(m);
26429      var g=document.getElementById('scheme-grid');
26430      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);});
26431      var cl=document.getElementById('settings-close');
26432      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);});})();
26433      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');});
26434      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26435      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26436    }
26437    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26438  }());
26439  </script>
26440  <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]';
26441  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;}
26442  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>
26443</body>
26444</html>
26445"##,
26446    ext = "html"
26447)]
26448struct ErrorTemplate {
26449    message: String,
26450    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
26451    last_report_url: Option<String>,
26452    /// Label for the secondary action button; defaults to "View last report" when None.
26453    last_report_label: Option<String>,
26454    /// Run ID to surface in the bug report; `None` when not applicable.
26455    run_id: Option<String>,
26456    /// HTTP status code to surface in the bug report; `None` when unknown.
26457    error_code: Option<u16>,
26458    csp_nonce: String,
26459    version: &'static str,
26460}
26461
26462// ── LocateFileTemplate ────────────────────────────────────────────────────────
26463
26464#[derive(Template)]
26465#[template(
26466    source = r##"
26467<!doctype html>
26468<html lang="en">
26469<head>
26470  <meta charset="utf-8">
26471  <meta name="viewport" content="width=device-width, initial-scale=1">
26472  <title>OxideSLOC | Locate Report</title>
26473  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26474  <style nonce="{{ csp_nonce }}">
26475    :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);}
26476    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26477    *{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;}
26478    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26479    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26480    .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);}
26481    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26482    .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));}
26483    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26484    .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;}
26485    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26486    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26487    @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;}}
26488    .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;}
26489    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26490    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26491    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26492    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26493    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26494    .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;}
26495    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26496    .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);}
26497    .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;}
26498    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26499    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26500    .settings-modal-body{padding:14px 16px 16px;}
26501    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26502    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26503    .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;}
26504    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26505    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26506    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26507    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26508    .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;}
26509    .tz-select:focus{border-color:var(--oxide);}
26510    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26511    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26512    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26513    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26514    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26515    .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;}
26516    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26517    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26518    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26519    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26520    .locate-row{display:flex;gap:8px;align-items:stretch;}
26521    .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;}
26522    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26523    body.dark-theme .locate-input{background:var(--surface-2);}
26524    .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;}
26525    .warning-banner.show{display:flex;}
26526    .warning-banner svg{flex:0 0 auto;}
26527    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26528    .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;}
26529    .error-inline.show{display:flex;}
26530    .error-inline svg{flex:0 0 auto;margin-top:2px;}
26531    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26532    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26533    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26534    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26535    .err-kv-p{margin:0 0 4px;}
26536    .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;}
26537    .success-inline.show{display:flex;}
26538    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26539    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26540    .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;}
26541    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26542    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26543    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26544    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26545    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26546    .fh-row:last-child{border-bottom:none;}
26547    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26548    .fh-dir{font-weight:800;color:var(--text);}
26549    .fh-hl{color:var(--oxide);font-weight:700;}
26550    .fh-muted{color:var(--muted);}
26551    .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;}
26552    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26553    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26554    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26555    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26556    .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;}
26557    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26558    .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;}
26559    .btn-secondary:hover{background:var(--line);}
26560    .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;}
26561    .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;}
26562    .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;}
26563    @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));}}
26564    .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;}
26565    .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;}
26566    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26567  </style>
26568</head>
26569<body>
26570  <div class="background-watermarks" aria-hidden="true">
26571    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26572    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26573    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26574    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26575    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26576    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26577  </div>
26578  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26579  <div class="top-nav">
26580    <div class="top-nav-inner">
26581      <a class="brand" href="/">
26582        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26583        <div class="brand-copy">
26584          <div class="brand-title">OxideSLOC</div>
26585          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26586        </div>
26587      </a>
26588      <div class="nav-right">
26589        <a class="nav-pill" href="/">Home</a>
26590        <div class="nav-dropdown">
26591          <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>
26592          <div class="nav-dropdown-menu">
26593            <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>
26594          </div>
26595        </div>
26596        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26597        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26598        <div class="nav-dropdown">
26599          <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>
26600          <div class="nav-dropdown-menu">
26601            <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>
26602          </div>
26603        </div>
26604        <div class="server-status-wrap" id="server-status-wrap">
26605          <div class="nav-pill server-online-pill" id="server-status-pill">
26606            <span class="status-dot" id="status-dot"></span>
26607            <span id="server-status-label">Server</span>
26608            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26609          </div>
26610          <div class="server-status-tip">
26611            OxideSLOC is running &mdash; accessible on your network.
26612            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26613          </div>
26614        </div>
26615        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26616          <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>
26617        </button>
26618        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26619          <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>
26620          <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>
26621        </button>
26622      </div>
26623    </div>
26624  </div>
26625
26626  <div class="page">
26627    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26628    <div class="panel">
26629      <h1>Report File Not Found</h1>
26630      <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>
26631      <div class="field-label">Missing file</div>
26632      <div class="filename-chip">
26633        <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>
26634        {{ expected_filename }}
26635      </div>
26636      <div class="locate-section">
26637        <h2>Locate Scan Output Folder</h2>
26638        <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>
26639        <p>OxideSLOC will find the correct files inside automatically.</p>
26640        <div class="locate-row">
26641          <input type="text" id="locate-file-input"
26642                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26643                 class="locate-input" autocomplete="off" spellcheck="false">
26644          {% if !server_mode %}
26645          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
26646          {% endif %}
26647        </div>
26648        <div class="warning-banner" id="filename-warning">
26649          <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>
26650          <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>
26651        </div>
26652        <div class="error-inline" id="locate-error">
26653          <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>
26654          <span id="locate-error-text"></span>
26655        </div>
26656        <div class="success-inline" id="locate-success">
26657          <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>
26658          <span>Scan restored &mdash; loading report&hellip;</span>
26659        </div>
26660        <div class="btn-row">
26661          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26662          <a class="btn-secondary" href="/view-reports">View Reports</a>
26663        </div>
26664        <div class="folder-hint-shell">
26665          <div class="folder-hint-hdr">
26666            <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>
26667            Expected Folder Structure &mdash; Select the Top-Level Folder
26668          </div>
26669          <div class="folder-hint-body">
26670            <div class="fh-row">
26671              <span class="fh-tog">&#9658;</span>
26672              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
26673              <span class="fh-badge">&larr; select this</span>
26674            </div>
26675            <div class="fh-row fh-i1">
26676              <span class="fh-tog">&#9658;</span>
26677              <span class="fh-dir">html/</span>
26678            </div>
26679            <div class="fh-row fh-i2">
26680              <span class="fh-bul">&#8226;</span>
26681              <span class="fh-hl">{{ expected_filename }}</span>
26682            </div>
26683            <div class="fh-row fh-i1">
26684              <span class="fh-tog">&#9658;</span>
26685              <span class="fh-dir">json/</span>
26686            </div>
26687            <div class="fh-row fh-i2">
26688              <span class="fh-bul">&#8226;</span>
26689              <span class="fh-muted">result_*.json</span>
26690            </div>
26691            <div class="fh-row fh-i1">
26692              <span class="fh-tog">&#9658;</span>
26693              <span class="fh-dir">pdf/</span>
26694            </div>
26695            <div class="fh-row fh-i2">
26696              <span class="fh-bul">&#8226;</span>
26697              <span class="fh-muted">report_*.pdf</span>
26698            </div>
26699            <div class="fh-row fh-i1">
26700              <span class="fh-tog">&#9658;</span>
26701              <span class="fh-dir">excel/</span>
26702            </div>
26703            <div class="fh-row fh-i2">
26704              <span class="fh-bul">&#8226;</span>
26705              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
26706            </div>
26707          </div>
26708        </div>
26709      </div>
26710    </div>
26711  </div>
26712  <footer class="site-footer">
26713    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26714    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26715    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26716    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26717    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26718  </footer>
26719  <script nonce="{{ csp_nonce }}">(function(){
26720    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26721    if(s==="dark")b.classList.add("dark-theme");
26722    document.getElementById("theme-toggle").addEventListener("click",function(){
26723      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26724    });
26725  })();</script>
26726  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26727    var c=document.getElementById('code-particles');if(!c)return;
26728    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'];
26729    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);}
26730  })();
26731  (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>
26732  <script nonce="{{ csp_nonce }}">(function(){
26733    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'}];
26734    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);});}
26735    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26736    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');});}
26737    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26738  }());</script>
26739  <script nonce="{{ csp_nonce }}">(function(){
26740    var meta=document.getElementById('locate-meta');
26741    var inp=document.getElementById('locate-file-input');
26742    var browseBtn=document.getElementById('browse-locate-btn');
26743    var submitBtn=document.getElementById('locate-submit-btn');
26744    var warning=document.getElementById('filename-warning');
26745    var errBox=document.getElementById('locate-error');
26746    var errText=document.getElementById('locate-error-text');
26747    var okBox=document.getElementById('locate-success');
26748    var expected=meta?meta.getAttribute('data-expected'):'';
26749    var runId=meta?meta.getAttribute('data-run-id'):'';
26750    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26751    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26752    function showErr(msg){
26753      if(errText){
26754        errText.innerHTML='';
26755        var lines=msg.split('\n');
26756        var hasPairs=lines.some(function(l){return / : /.test(l);});
26757        if(!hasPairs){errText.textContent=msg;}
26758        else{
26759          var frag=document.createDocumentFragment();var tbl=null;
26760          lines.forEach(function(line){
26761            var m=line.match(/^(.*?) : (.*)$/);
26762            if(m){
26763              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26764              var tr=document.createElement('tr');
26765              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26766              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26767              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26768            } else {
26769              tbl=null;
26770              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26771            }
26772          });
26773          errText.appendChild(frag);
26774        }
26775      }
26776      if(errBox)errBox.classList.add('show');
26777      if(okBox)okBox.classList.remove('show');
26778    }
26779    function clearErr(){
26780      if(errBox)errBox.classList.remove('show');
26781      if(okBox)okBox.classList.remove('show');
26782    }
26783    function validate(){
26784      var val=inp?inp.value.trim():'';
26785      clearErr();
26786      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26787      if(submitBtn)submitBtn.disabled=false;
26788      if(warning){
26789        var name=basename(val);
26790        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26791        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26792        else warning.classList.remove('show');
26793      }
26794    }
26795    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26796    if(browseBtn){
26797      browseBtn.addEventListener('click',function(){
26798        browseBtn.disabled=true;browseBtn.textContent='...';
26799        fetch('/pick-directory')
26800          .then(function(r){return r.ok?r.json():{cancelled:true};})
26801          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26802          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26803      });
26804    }
26805    if(submitBtn){
26806      submitBtn.addEventListener('click',function(){
26807        var folder=inp?inp.value.trim():'';
26808        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26809        clearErr();
26810        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26811        var body=new URLSearchParams();
26812        body.set('file_path',folder);
26813        body.set('redirect_url',redirectUrl);
26814        body.set('expected_run_id',runId);
26815        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26816          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26817          .then(function(d){
26818            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26819            if(d&&d.ok){
26820              if(okBox)okBox.classList.add('show');
26821              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26822            } else {
26823              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26824            }
26825          })
26826          .catch(function(e){
26827            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26828            showErr('Network error: '+String(e));
26829          });
26830      });
26831    }
26832  })();</script>
26833  <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>
26834</body>
26835</html>
26836"##,
26837    ext = "html"
26838)]
26839struct LocateFileTemplate {
26840    run_id: String,
26841    artifact_type: String,
26842    expected_filename: String,
26843    server_mode: bool,
26844    csp_nonce: String,
26845    version: &'static str,
26846}
26847
26848// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26849
26850#[derive(Template)]
26851#[template(
26852    source = r##"
26853<!doctype html>
26854<html lang="en">
26855<head>
26856  <meta charset="utf-8">
26857  <meta name="viewport" content="width=device-width, initial-scale=1">
26858  <title>OxideSLOC | Locate Scan Files</title>
26859  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26860  <style nonce="{{ csp_nonce }}">
26861    :root {
26862      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26863      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26864      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26865      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26866    }
26867    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26868    *{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;}
26869    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26870    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26871    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26872    .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);}
26873    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26874    .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));}
26875    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26876    .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;}
26877    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26878    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26879    @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;}}
26880    .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;}
26881    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26882    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26883    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26884    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26885    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26886    .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;}
26887    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26888    .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);}
26889    .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;}
26890    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26891    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26892    .settings-modal-body{padding:14px 16px 16px;}
26893    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26894    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26895    .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;}
26896    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26897    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26898    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26899    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26900    .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;}
26901    .tz-select:focus{border-color:var(--oxide);}
26902    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26903    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26904    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26905    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26906    .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;}
26907    .error-box.hidden{display:none;}
26908    .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;}
26909    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26910    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26911    .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;}
26912    .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;}
26913    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26914    .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;}
26915    .btn-secondary:hover{background:var(--line);}
26916    .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;}
26917    .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;}
26918    .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;}
26919    @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));}}
26920    .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;}
26921    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26922    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26923    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26924    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26925    .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;}
26926    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26927    body.dark-theme .relocate-input{background:var(--surface-2);}
26928  </style>
26929</head>
26930<body>
26931  <div class="background-watermarks" aria-hidden="true">
26932    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26933    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26934    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26935    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26936    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26937    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26938  </div>
26939  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26940  <div class="top-nav">
26941    <div class="top-nav-inner">
26942      <a class="brand" href="/">
26943        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26944        <div class="brand-copy">
26945          <div class="brand-title">OxideSLOC</div>
26946          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26947        </div>
26948      </a>
26949      <div class="nav-right">
26950        <a class="nav-pill" href="/">Home</a>
26951        <div class="nav-dropdown">
26952          <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>
26953          <div class="nav-dropdown-menu">
26954            <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>
26955          </div>
26956        </div>
26957        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26958        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26959        <div class="nav-dropdown">
26960          <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>
26961          <div class="nav-dropdown-menu">
26962            <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>
26963          </div>
26964        </div>
26965        <div class="server-status-wrap" id="server-status-wrap">
26966          <div class="nav-pill server-online-pill" id="server-status-pill">
26967            <span class="status-dot" id="status-dot"></span>
26968            <span id="server-status-label">Server</span>
26969            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26970          </div>
26971          <div class="server-status-tip">
26972            OxideSLOC is running — accessible on your network.
26973            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26974          </div>
26975        </div>
26976        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26977          <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>
26978        </button>
26979        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26980          <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>
26981          <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>
26982        </button>
26983      </div>
26984    </div>
26985  </div>
26986
26987  <div class="page">
26988    <div class="panel">
26989      <h1>Scan Files Moved</h1>
26990      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26991      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26992      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26993      <div class="relocate-section">
26994        <h2>Locate Scan Output</h2>
26995        <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>
26996        <div class="relocate-row">
26997          <input type="text" id="relocate-folder" name="folder_path"
26998                 value="{{ folder_hint }}"
26999                 placeholder="Path to folder containing scan output..."
27000                 class="relocate-input" autocomplete="off" spellcheck="false">
27001          {% if !server_mode %}
27002          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
27003          {% endif %}
27004        </div>
27005        <div style="margin-top:12px;">
27006          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27007        </div>
27008      </div>
27009      <div class="actions">
27010        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27011        <a class="btn-secondary" href="/view-reports">View Reports</a>
27012      </div>
27013    </div>
27014  </div>
27015  <footer class="site-footer">
27016    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
27017    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27018    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27019    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27020    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27021  </footer>
27022  <script nonce="{{ csp_nonce }}">
27023    (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");});})();
27024    (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);}})();
27025    (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;});})();
27026  </script>
27027  <script nonce="{{ csp_nonce }}">
27028  (function(){
27029    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'}];
27030    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);});}
27031    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27032    function init(){
27033      var btn=document.getElementById('settings-btn');if(!btn)return;
27034      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27035      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>';
27036      document.body.appendChild(m);
27037      var g=document.getElementById('scheme-grid');
27038      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);});
27039      var cl=document.getElementById('settings-close');
27040      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);});})();
27041      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');});
27042      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27043      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27044    }
27045    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27046  }());
27047  (function(){
27048    var browseBtn=document.getElementById('browse-relocate-btn');
27049    if(browseBtn){
27050      browseBtn.addEventListener('click',function(){
27051        browseBtn.disabled=true;browseBtn.textContent='...';
27052        var inp=document.getElementById('relocate-folder');
27053        var hint=inp?inp.value:'';
27054        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
27055          .then(function(r){return r.ok?r.json():{cancelled:true};})
27056          .then(function(d){
27057            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27058            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27059          })
27060          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27061      });
27062    }
27063    var restoreBtn=document.getElementById('restore-btn');
27064    var errBox=document.getElementById('relocate-error-box');
27065    var okBox=document.getElementById('relocate-success-box');
27066    if(restoreBtn){
27067      restoreBtn.addEventListener('click',function(){
27068        var inp=document.getElementById('relocate-folder');
27069        var folder=inp?inp.value.trim():'';
27070        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27071        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27072        var body=new URLSearchParams();
27073        body.set('run_id','{{ run_id }}');
27074        body.set('redirect_url','{{ redirect_url }}');
27075        body.set('folder_path',folder);
27076        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27077          .then(function(r){return r.json();})
27078          .then(function(d){
27079            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27080            if(d&&d.ok){
27081              if(errBox)errBox.classList.add('hidden');
27082              if(okBox){okBox.style.display='block';}
27083              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27084            } else {
27085              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27086            }
27087          })
27088          .catch(function(e){
27089            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27090            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27091          });
27092      });
27093    }
27094  }());
27095  </script>
27096  <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]';
27097  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;}
27098  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>
27099</body>
27100</html>
27101"##,
27102    ext = "html"
27103)]
27104struct RelocateScanTemplate {
27105    message: String,
27106    run_id: String,
27107    folder_hint: String,
27108    redirect_url: String,
27109    server_mode: bool,
27110    csp_nonce: String,
27111    version: &'static str,
27112}
27113
27114// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
27115
27116#[derive(Template)]
27117#[template(
27118    source = r##"
27119<!doctype html>
27120<html lang="en">
27121<head>
27122  <meta charset="utf-8">
27123  <meta name="viewport" content="width=device-width, initial-scale=1">
27124  <title>OxideSLOC | View Reports</title>
27125  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27126  <style nonce="{{ csp_nonce }}">
27127    :root {
27128      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27129      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27130      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27131      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27132      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27133    }
27134    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; }
27135    *{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;}
27136    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27137    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27138    .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);}
27139    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27140    .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));}
27141    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27142    .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;}
27143    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27144    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27145    @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; } }
27146    .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;}
27147    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27148    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27149    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27150    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27151    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27152    .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;}
27153    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27154    .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);}
27155    .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;}
27156    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27157    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27158    .settings-modal-body{padding:14px 16px 16px;}
27159    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27160    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27161    .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;}
27162    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27163    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27164    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27165    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27166    .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;}
27167    .tz-select:focus{border-color:var(--oxide);}
27168    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27169    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27170    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27171    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27172    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27173    .panel-meta{font-size:13px;color:var(--muted);}
27174    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27175    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27176    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27177    .per-page-label{font-size:13px;color:var(--muted);}
27178    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;}
27179    .filter-input{min-width:180px;cursor:text;}
27180    .table-wrap{width:100%;overflow-x:auto;}
27181    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27182    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;}
27183    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27184    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27185    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27186    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27187    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27188    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27189    tr:last-child td{border-bottom:none;}
27190    tr:hover td{background:var(--surface-2);}
27191    .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);}
27192    .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);}
27193    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27194    .metric-num{font-weight:700;color:var(--text);}
27195    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27196    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27197    .git-commit-chip{cursor:help;}
27198    .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;}
27199    .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;}
27200    .btn:hover{background:var(--line);}
27201    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27202    .btn.primary:hover{opacity:.9;}
27203    .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;}
27204    .btn-back:hover{background:var(--line);}
27205    .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;}
27206    .export-btn:hover{background:var(--line);}
27207    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27208    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27209    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27210    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27211    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27212    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27213    .pagination-info{font-size:13px;color:var(--muted);}
27214    .pagination-btns{display:flex;gap:6px;}
27215    .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;}
27216    .pg-btn:hover:not(:disabled){background:var(--line);}
27217    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27218    .pg-btn:disabled{opacity:.35;cursor:default;}
27219    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27220    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27221    .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);}
27222    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27223    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27224    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27225    .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);}
27226    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27227    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27228    .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;}
27229    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27230    .site-footer a{color:var(--muted);}
27231    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27232    .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%;}
27233    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27234    .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;}
27235    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27236    .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;}
27237    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27238    .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;}
27239    .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;}
27240    .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;}
27241    @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));}}
27242    .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;}
27243    .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;}
27244    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27245    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27246    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27247    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27248    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27249    .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;}
27250    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27251    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27252    .watched-chip-rm:hover{color:var(--oxide);}
27253    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27254    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27255    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27256    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27257    .rpt-btn{min-width:58px;justify-content:center;}
27258    .flex-row{display:flex;align-items:center;gap:8px;}
27259    .report-cell{overflow:visible;white-space:normal;}
27260    #history-table col:nth-child(1){width:185px;}
27261    #history-table col:nth-child(2){width:220px;}
27262    #history-table col:nth-child(3){width:100px;}
27263    #history-table col:nth-child(4){width:72px;}
27264    #history-table col:nth-child(5){width:82px;}
27265    #history-table col:nth-child(6){width:82px;}
27266    #history-table col:nth-child(7){width:65px;}
27267    #history-table col:nth-child(8){width:90px;}
27268    #history-table col:nth-child(9){width:85px;}
27269    #history-table col:nth-child(10){width:115px;}
27270    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27271    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27272    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27273    .submod-details summary::-webkit-details-marker{display:none;}
27274.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27275    .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;}
27276    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27277    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27278  </style>
27279</head>
27280<body>
27281  <div class="background-watermarks" aria-hidden="true">
27282    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27283    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27284    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27285    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27286    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27287    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27288  </div>
27289  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27290  <div class="top-nav">
27291    <div class="top-nav-inner">
27292      <a class="brand" href="/">
27293        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27294        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27295      </a>
27296      <div class="nav-right">
27297        <a class="nav-pill" href="/">Home</a>
27298        <div class="nav-dropdown">
27299          <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>
27300          <div class="nav-dropdown-menu">
27301            <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>
27302          </div>
27303        </div>
27304        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27305        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27306        <div class="nav-dropdown">
27307          <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>
27308          <div class="nav-dropdown-menu">
27309            <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>
27310          </div>
27311        </div>
27312        <div class="server-status-wrap" id="server-status-wrap">
27313          <div class="nav-pill server-online-pill" id="server-status-pill">
27314            <span class="status-dot" id="status-dot"></span>
27315            <span id="server-status-label">Server</span>
27316            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27317          </div>
27318          <div class="server-status-tip">
27319            OxideSLOC is running — accessible on your network.
27320            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27321          </div>
27322        </div>
27323        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27324          <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>
27325        </button>
27326        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27327          <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>
27328          <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>
27329        </button>
27330      </div>
27331    </div>
27332  </div>
27333
27334  <div class="page">
27335    {% if let Some(err) = browse_error %}
27336    <div class="toast-error">
27337      <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>
27338      {{ err }}
27339    </div>
27340    {% endif %}
27341    {% if linked_count > 0 %}
27342    <div class="toast-success">
27343      <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>
27344      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27345    </div>
27346    {% endif %}
27347    <div class="watched-bar">
27348      <div class="watched-bar-left">
27349        <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>
27350        <span class="watched-label">Watched Folders</span>
27351        <div class="watched-chips">
27352          {% if server_mode %}
27353          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27354          {% else %}
27355          {% for dir in watched_dirs %}
27356          <span class="watched-chip">
27357            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27358            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27359              <input type="hidden" name="folder_path" value="{{ dir }}">
27360              <input type="hidden" name="redirect_to" value="/view-reports">
27361              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27362            </form>
27363          </span>
27364          {% endfor %}
27365          {% if watched_dirs.is_empty() %}
27366          <span class="watched-none">No folders watched — click Choose to add one</span>
27367          {% endif %}
27368          {% endif %}
27369        </div>
27370      </div>
27371      {% if !server_mode %}
27372      <div class="watched-bar-right">
27373        <button type="button" class="btn" id="add-watched-btn">
27374          <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>
27375          Choose
27376        </button>
27377        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27378          <input type="hidden" name="redirect_to" value="/view-reports">
27379          <button type="submit" class="btn">&#8635; Refresh</button>
27380        </form>
27381      </div>
27382      {% endif %}
27383    </div>
27384    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27385      <div class="scan-overlay-card">
27386        <div class="scan-spinner"></div>
27387        <div class="scan-overlay-text">Scanning folder…</div>
27388        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27389      </div>
27390    </div>
27391    <style>
27392    .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);}
27393    .scan-overlay.active{display:flex;}
27394    .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;}
27395    .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;}
27396    @keyframes scanSpin{to{transform:rotate(360deg);}}
27397    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27398    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27399    </style>
27400    {% if total_scans > 0 %}
27401    <div class="summary-strip">
27402      <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>
27403      <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>
27404      <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>
27405      <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>
27406    </div>
27407    {% endif %}
27408
27409    <section class="panel">
27410      <div class="panel-header">
27411        <div>
27412          <h1>View Reports</h1>
27413          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27414          {% 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 %}
27415        </div>
27416        <div class="flex-row">
27417          <button type="button" class="export-btn" id="export-csv-btn">
27418            <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>
27419            Export CSV
27420          </button>
27421          <button type="button" class="export-btn" id="export-xls-btn">
27422            <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>
27423            Export Excel
27424          </button>
27425        </div>
27426      </div>
27427
27428      {% if entries.is_empty() %}
27429      <div class="empty-state">
27430        <strong>No reports with viewable HTML yet</strong>
27431        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.
27432      </div>
27433      {% else %}
27434      <div class="filter-row">
27435        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27436        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27437        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27438      </div>
27439      <div class="table-wrap">
27440        <table id="history-table">
27441          <colgroup>
27442            <col><col><col><col><col><col><col><col><col><col>
27443          </colgroup>
27444          <thead>
27445            <tr id="history-thead">
27446              <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>
27447              <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>
27448              <th>Run ID<div class="col-resize-handle"></div></th>
27449              <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>
27450              <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>
27451              <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>
27452              <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>
27453              <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>
27454              <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>
27455              <th>Report<div class="col-resize-handle"></div></th>
27456            </tr>
27457          </thead>
27458          <tbody id="history-tbody">
27459            {% for entry in entries %}
27460            <tr class="history-row" data-run="{{ entry.run_id }}"
27461                data-timestamp="{{ entry.timestamp }}"
27462                data-project="{{ entry.project_label }}"
27463                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27464                data-skipped="{{ entry.files_skipped }}"
27465                data-comments="{{ entry.comment_lines }}"
27466                data-blank="{{ entry.blank_lines }}"
27467                data-physical="{{ entry.total_physical_lines }}"
27468                data-functions="{{ entry.functions }}"
27469                data-classes="{{ entry.classes }}"
27470                data-variables="{{ entry.variables }}"
27471                data-imports="{{ entry.imports }}"
27472                data-tests="{{ entry.test_count }}"
27473                data-branch="{{ entry.git_branch }}"
27474                data-commit="{{ entry.git_commit }}"
27475                data-has-json="{{ entry.has_json }}"
27476                data-html-url="/runs/html/{{ entry.run_id }}">
27477              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27478              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27479              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27480              <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>
27481              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27482              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27483              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27484              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
27485              <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>
27486              <td class="report-cell">
27487                <div class="actions-cell">
27488                  {% 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 %}
27489                  {% 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 %}
27490                </div>
27491                {% if !entry.submodule_links.is_empty() %}
27492                <details class="submod-details">
27493                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
27494                  <div class="submod-link-list">
27495                    {% for sub in entry.submodule_links %}
27496                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27497                    {% endfor %}
27498                  </div>
27499                </details>
27500                {% endif %}
27501              </td>
27502            </tr>
27503            {% endfor %}
27504          </tbody>
27505        </table>
27506      </div>
27507      <div class="pagination">
27508        <span class="pagination-info" id="pagination-info"></span>
27509        <div class="pagination-btns" id="pagination-btns"></div>
27510        <div class="flex-row">
27511          <span class="per-page-label">Show</span>
27512          <select class="per-page" id="per-page-sel">
27513            <option value="10">10 per page</option>
27514            <option value="25" selected>25 per page</option>
27515            <option value="50">50 per page</option>
27516            <option value="100">100 per page</option>
27517          </select>
27518          <span class="per-page-label" id="page-range-label"></span>
27519        </div>
27520      </div>
27521      {% endif %}
27522    </section>
27523  </div>
27524
27525  <footer class="site-footer">
27526    local code analysis - metrics, history and reports
27527    &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>
27528    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27529    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27530    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27531    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27532  </footer>
27533
27534  <script nonce="{{ csp_nonce }}">
27535    (function () {
27536      // ── Theme ──────────────────────────────────────────────────────────────
27537      var storageKey = 'oxide-sloc-theme';
27538      var body = document.body;
27539      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27540      var toggle = document.getElementById('theme-toggle');
27541      if (toggle) toggle.addEventListener('click', function () {
27542        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27543        body.classList.toggle('dark-theme', next === 'dark');
27544        try { localStorage.setItem(storageKey, next); } catch(e) {}
27545      });
27546
27547      // ── State ─────────────────────────────────────────────────────────────
27548      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27549      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27550      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27551
27552      // Aggregate stats from first (most recent) row
27553      if (allRows.length) {
27554        var first = allRows[0];
27555        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();}
27556        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>':'');}
27557        setChipVal('agg-code', first.dataset.code);
27558        setChipVal('agg-files', first.dataset.files);
27559        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27560        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27561        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(); });
27562      }
27563
27564      // ── Branch filter population ──────────────────────────────────────────
27565      (function() {
27566        var branches = {};
27567        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27568        var sel = document.getElementById('branch-filter');
27569        if (sel) Object.keys(branches).sort().forEach(function(b) {
27570          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27571        });
27572      })();
27573
27574      // ── Filter ────────────────────────────────────────────────────────────
27575      function getFilteredRows() {
27576        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27577        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27578        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27579          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27580          if (branch && (r.dataset.branch || '') !== branch) return false;
27581          return true;
27582        });
27583      }
27584
27585      // ── Pagination ────────────────────────────────────────────────────────
27586      function renderPage() {
27587        var filtered = getFilteredRows();
27588        var total = filtered.length;
27589        var totalPages = Math.max(1, Math.ceil(total / perPage));
27590        currentPage = Math.min(currentPage, totalPages);
27591        var start = (currentPage - 1) * perPage;
27592        var end = Math.min(start + perPage, total);
27593        var shown = {};
27594        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27595        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27596          r.style.display = shown[r.dataset.run] ? '' : 'none';
27597        });
27598        var rl = document.getElementById('page-range-label');
27599        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27600        var info = document.getElementById('pagination-info');
27601        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27602        var btns = document.getElementById('pagination-btns');
27603        if (!btns) return;
27604        btns.innerHTML = '';
27605        function makeBtn(lbl, pg, active, disabled) {
27606          var b = document.createElement('button');
27607          b.className = 'pg-btn' + (active ? ' active' : '');
27608          b.textContent = lbl; b.disabled = disabled;
27609          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27610          return b;
27611        }
27612        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27613        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27614        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27615        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27616      }
27617
27618      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27619      window.applyFilters = function() { currentPage = 1; renderPage(); };
27620
27621      // ── Sorting ───────────────────────────────────────────────────────────
27622      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27623      function doSort(col, type, order) {
27624        var tbody = document.getElementById('history-tbody');
27625        if (!tbody) return;
27626        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27627        rows.sort(function(a, b) {
27628          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27629          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27630          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27631          return va < vb ? 1 : va > vb ? -1 : 0;
27632        });
27633        rows.forEach(function(r) { tbody.appendChild(r); });
27634        currentPage = 1; renderPage();
27635      }
27636      sortHeaders.forEach(function(th) {
27637        th.addEventListener('click', function(e) {
27638          if (e.target.classList.contains('col-resize-handle')) return;
27639          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27640          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27641          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27642          th.classList.add('sort-' + sortOrder);
27643          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27644          doSort(col, type, sortOrder);
27645        });
27646      });
27647
27648      // ── Column resize ─────────────────────────────────────────────────────
27649      (function() {
27650        var table = document.getElementById('history-table');
27651        if (!table) return;
27652        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27653        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27654        ths.forEach(function(th, i) {
27655          var handle = th.querySelector('.col-resize-handle');
27656          if (!handle || !cols[i]) return;
27657          var startX, startW;
27658          handle.addEventListener('mousedown', function(e) {
27659            e.stopPropagation(); e.preventDefault();
27660            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27661            handle.classList.add('dragging');
27662            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27663            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27664            document.addEventListener('mousemove', onMove);
27665            document.addEventListener('mouseup', onUp);
27666          });
27667        });
27668      })();
27669
27670      // ── Full-commit hover tooltip ─────────────────────────────────────────
27671      // The commit chips live inside an overflow:auto table wrapper, which would
27672      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27673      // (escaping the scroll container) and follow the cursor. Event delegation
27674      // keeps it working after pagination/sorting re-renders the rows.
27675      (function() {
27676        var tip = document.createElement('div');
27677        tip.className = 'commit-tip';
27678        tip.setAttribute('role', 'tooltip');
27679        document.body.appendChild(tip);
27680        var shown = false;
27681        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27682        function place(e) {
27683          var pad = 14, r = tip.getBoundingClientRect();
27684          var x = e.clientX + pad, y = e.clientY + pad;
27685          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27686          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27687          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27688        }
27689        function hide() { tip.style.display = 'none'; shown = false; }
27690        document.addEventListener('mouseover', function(e) {
27691          var chip = chipFrom(e.target);
27692          if (!chip) return;
27693          var full = chip.getAttribute('data-full-commit');
27694          if (!full) return;
27695          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27696        });
27697        document.addEventListener('mousemove', function(e) {
27698          if (!shown) return;
27699          if (chipFrom(e.target)) place(e); else hide();
27700        });
27701        document.addEventListener('mouseout', function(e) {
27702          if (chipFrom(e.target)) hide();
27703        });
27704      })();
27705
27706      // ── Reset view ────────────────────────────────────────────────────────
27707      window.resetView = function() {
27708        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27709        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27710        sortCol = null; sortOrder = 'asc';
27711        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27712        var tbody = document.getElementById('history-tbody');
27713        if (tbody) {
27714          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27715          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27716          rows.forEach(function(r) { tbody.appendChild(r); });
27717        }
27718        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27719        var table = document.getElementById('history-table');
27720        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27721        currentPage = 1; renderPage();
27722      };
27723
27724      renderPage();
27725
27726      // ── Export helpers ────────────────────────────────────────────────────
27727      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
27728      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27729      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);}
27730      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;');}
27731      function slocXlsx(fname,sheet,hdrs,rows){
27732        var enc=new TextEncoder();
27733        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;}
27734        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;}
27735        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27736        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27737        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27738        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;}
27739        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27740        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];}
27741        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27742        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27743        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27744          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27745          +'<fonts count="2">'
27746            +'<font><sz val="11"/><name val="Calibri"/></font>'
27747            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27748          +'</fonts>'
27749          +'<fills count="3">'
27750            +'<fill><patternFill patternType="none"/></fill>'
27751            +'<fill><patternFill patternType="gray125"/></fill>'
27752            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27753          +'</fills>'
27754          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27755          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27756          +'<cellXfs count="4">'
27757            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27758            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27759            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27760            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27761          +'</cellXfs>'
27762          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27763          +'</styleSheet>';
27764        var rx='<row r="1">';
27765        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27766        rx+='</row>';
27767        rows.forEach(function(row,ri){
27768          var rn=ri+2;rx+='<row r="'+rn+'">';
27769          row.forEach(function(cell,c){
27770            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27771            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27772            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27773            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27774            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27775            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27776          });
27777          rx+='</row>';
27778        });
27779        var lastCol=hdrs.length,lastRow=rows.length+1;
27780        var tableRef='A1:'+colNm(lastCol)+lastRow;
27781        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27782          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27783          +'<autoFilter ref="'+tableRef+'"/>'
27784          +'<tableColumns count="'+lastCol+'">'
27785          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27786          +'</tableColumns>'
27787          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27788          +'</table>';
27789        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27790          +'<Relationships xmlns="'+pns+'relationships">'
27791          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27792          +'</Relationships>';
27793        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>';
27794        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27795          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27796          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27797          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27798          +'</worksheet>';
27799        var F={
27800          '[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>',
27801          '_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>',
27802          '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>',
27803          '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>',
27804          'xl/styles.xml':stl,
27805          'xl/sharedStrings.xml':ssXml,
27806          'xl/worksheets/sheet1.xml':sh,
27807          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27808          'xl/tables/table1.xml':tableXml
27809        };
27810        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'];
27811        var zparts=[],zcds=[],zoff=0,znf=0;
27812        order.forEach(function(name){
27813          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27814          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]);
27815          var entry=new Uint8Array(lha.length+nb.length+sz);
27816          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27817          zparts.push(entry);
27818          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));
27819          var cde=new Uint8Array(cda.length+nb.length);
27820          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27821          zcds.push(cde);zoff+=entry.length;znf++;
27822        });
27823        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27824        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]);
27825        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27826        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27827        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27828        zout.set(new Uint8Array(ea),zpos);
27829        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27830      }
27831
27832      // Multi-sheet XLSX builder for the scan-history export.
27833      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27834      function slocXlsxMulti(fname,sheets){
27835        var enc=new TextEncoder();
27836        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;}
27837        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;}
27838        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27839        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27840        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27841        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];}
27842        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;}
27843        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27844        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27845        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27846          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27847          +'<fonts count="3">'
27848            +'<font><sz val="11"/><name val="Calibri"/></font>'
27849            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27850            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27851          +'</fonts>'
27852          +'<fills count="4">'
27853            +'<fill><patternFill patternType="none"/></fill>'
27854            +'<fill><patternFill patternType="gray125"/></fill>'
27855            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27856            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27857          +'</fills>'
27858          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27859          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27860          +'<cellXfs count="7">'
27861            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27862            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27863            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27864            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27865            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27866            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27867            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27868          +'</cellXfs>'
27869          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27870          +'</styleSheet>';
27871        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27872        sheets.forEach(function(sh,sheetIdx){
27873          var rx='<row r="1">';
27874          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27875          rx+='</row>';
27876          var rn=2;
27877          sh.rows.forEach(function(row){
27878            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27879            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27880              rx+='<row r="'+rn+'">';
27881              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27882              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27883              rx+='</row>';rn++;return;
27884            }
27885            rx+='<row r="'+rn+'">';
27886            row.forEach(function(cell,c){
27887              var ref=colRef(c,rn);
27888              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27889              if(typeof cell==='object'&&cell!==null){
27890                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27891                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27892                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27893                return;
27894              }
27895              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27896              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27897            });
27898            rx+='</row>';rn++;
27899          });
27900          var cw='';
27901          if(sh.colWidths&&sh.colWidths.length>0){
27902            cw='<cols>';
27903            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27904            cw+='</cols>';
27905          }
27906          var tblParts='';
27907          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27908            tableCounter++;
27909            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27910            var tRef='A1:'+colNm(colCount)+rowCount;
27911            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27912              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27913              +'<autoFilter ref="'+tRef+'"/>'
27914              +'<tableColumns count="'+colCount+'">'
27915              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27916              +'</tableColumns>'
27917              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27918              +'</table>';
27919            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27920              +'<Relationships xmlns="'+pns+'relationships">'
27921              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27922              +'</Relationships>';
27923            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27924          }
27925          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27926            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27927            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27928        });
27929        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>';
27930        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27931        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27932        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>';
27933        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27934        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27935        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27936        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27937          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27938        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27939        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};
27940        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27941        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27942        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27943        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27944        var zparts=[],zcds=[],zoff=0,znf=0;
27945        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++;});
27946        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27947        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]);
27948        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27949        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27950        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27951        zout.set(new Uint8Array(ea),zpos);
27952        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27953      }
27954
27955      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'};
27956      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27957
27958      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'];
27959      function getHistoryRows(){
27960        var r=[];
27961        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27962          var code=Number(tr.getAttribute('data-code'))||0;
27963          var phys=Number(tr.getAttribute('data-physical'))||0;
27964          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27965          r.push([
27966            tr.getAttribute('data-timestamp')||'',
27967            tr.getAttribute('data-project')||'',
27968            tr.getAttribute('data-run')||'',
27969            tr.getAttribute('data-physical')||'',
27970            tr.getAttribute('data-code')||'',
27971            tr.getAttribute('data-comments')||'',
27972            tr.getAttribute('data-blank')||'',
27973            tr.getAttribute('data-files')||'',
27974            tr.getAttribute('data-skipped')||'',
27975            tr.getAttribute('data-functions')||'',
27976            tr.getAttribute('data-classes')||'',
27977            tr.getAttribute('data-variables')||'',
27978            tr.getAttribute('data-imports')||'',
27979            tr.getAttribute('data-tests')||'',
27980            dens,
27981            tr.getAttribute('data-branch')||'',
27982            tr.getAttribute('data-commit')||''
27983          ]);
27984        });
27985        return r;
27986      }
27987      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27988      window.exportHistoryXls = function(){
27989        var histRows=getHistoryRows();
27990        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27991        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]];});
27992        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]};
27993        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27994        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27995        var runId=jsonRow.getAttribute('data-run')||'';
27996        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27997        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27998        fetch('/runs/json/'+runId)
27999          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
28000          .then(function(run){
28001            var tot=run.summary_totals||{};
28002            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28003            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28004            function B(v){return{v:v,s:4};}
28005            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28006            var sumRows=[
28007              [{_sec:true,v:'RUN INFORMATION'}],
28008              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28009              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28010              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28011              [B('Branch'),run.git_branch||''],
28012              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28013              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28014              [B('Files Analyzed'),N(tot.files_analyzed)],
28015              [B('Files Skipped'),N(tot.files_skipped)],
28016              [],
28017              [{_sec:true,v:'CODE METRICS'}],
28018              [B('Physical Lines'),N(phys)],
28019              [B('Code Lines'),N(code)],
28020              [B('Comments'),N(tot.comment_lines)],
28021              [B('Blank Lines'),N(tot.blank_lines)],
28022              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28023              [B('Functions'),N(tot.functions)],
28024              [B('Classes / Types'),N(tot.classes)],
28025              [B('Variables'),N(tot.variables)],
28026              [B('Imports'),N(tot.imports)],
28027              [B('Tests'),N(tot.test_count)],
28028              [B('Assertions'),N(tot.test_assertion_count)],
28029              [B('Test Suites'),N(tot.test_suite_count)],
28030              [B('Code Density'),{v:dens,s:6}],
28031              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28032            ];
28033            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28034            var langRows=(run.totals_by_language||[]).map(function(l){
28035              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28036              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28037              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];
28038            });
28039            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28040            var pfRows=(run.per_file_records||[]).map(function(r){
28041              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28042              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];
28043            });
28044            var skHdrs=['File','Status','Size (bytes)'];
28045            var skRows=(run.skipped_file_records||[]).map(function(r){
28046              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28047            });
28048            slocXlsxMulti('scan-history.xlsx',[
28049              histSheet,
28050              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28051              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28052              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28053              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28054            ]);
28055          })
28056          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28057      };
28058
28059      var csvBtn = document.getElementById('export-csv-btn');
28060      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28061      var xlsBtn = document.getElementById('export-xls-btn');
28062      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28063
28064      // ── Remaining CSP-safe event bindings ────────────────────────────────
28065      (function wireEvents() {
28066        var el;
28067        el = document.getElementById('reset-view-btn');
28068        if (el) el.addEventListener('click', window.resetView);
28069        el = document.getElementById('project-filter');
28070        if (el) el.addEventListener('input', window.applyFilters);
28071        el = document.getElementById('branch-filter');
28072        if (el) el.addEventListener('change', window.applyFilters);
28073        el = document.getElementById('per-page-sel');
28074        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28075        (function(){
28076          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');};
28077          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);
28078        })();
28079        el = document.getElementById('add-watched-btn');
28080        if (el) el.addEventListener('click', function() {
28081          fetch('/pick-directory?kind=reports')
28082            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28083            .then(function(data) {
28084              if (!data.cancelled && data.selected_path) {
28085                var form = document.createElement('form');
28086                form.method = 'POST';
28087                form.action = '/watched-dirs/add';
28088                var ri = document.createElement('input');
28089                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28090                var fi = document.createElement('input');
28091                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28092                form.appendChild(ri); form.appendChild(fi);
28093                document.body.appendChild(form);
28094                if (window.__scanOverlay) window.__scanOverlay();
28095                form.submit();
28096              }
28097            })
28098            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28099        });
28100      })();
28101
28102      (function randomizeWatermarks() {
28103        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28104        if (!wms.length) return;
28105        var placed = [];
28106        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;}
28107        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];}
28108        var half=Math.floor(wms.length/2);
28109        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;});
28110      })();
28111
28112      (function spawnCodeParticles() {
28113        var container = document.getElementById('code-particles');
28114        if (!container) return;
28115        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'];
28116        for (var i = 0; i < 38; i++) {
28117          (function(idx) {
28118            var el = document.createElement('span');
28119            el.className = 'code-particle';
28120            el.textContent = snippets[idx % snippets.length];
28121            var left = Math.random() * 94 + 2;
28122            var top = Math.random() * 88 + 6;
28123            var dur = (Math.random() * 10 + 9).toFixed(1);
28124            var delay = (Math.random() * 18).toFixed(1);
28125            var rot = (Math.random() * 26 - 13).toFixed(1);
28126            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28127            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';
28128            container.appendChild(el);
28129          })(i);
28130        }
28131      })();
28132    })();
28133  </script>
28134  <script nonce="{{ csp_nonce }}">
28135  (function(){
28136    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'}];
28137    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);});}
28138    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28139    function init(){
28140      var btn=document.getElementById('settings-btn');if(!btn)return;
28141      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28142      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>';
28143      document.body.appendChild(m);
28144      var g=document.getElementById('scheme-grid');
28145      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);});
28146      var cl=document.getElementById('settings-close');
28147      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);});})();
28148      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');});
28149      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28150      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28151    }
28152    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28153  }());
28154  </script>
28155  <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>
28156</body>
28157</html>
28158"##,
28159    ext = "html"
28160)]
28161struct HistoryTemplate {
28162    version: &'static str,
28163    entries: Vec<HistoryEntryRow>,
28164    total_scans: usize,
28165    linked_count: usize,
28166    browse_error: Option<String>,
28167    watched_dirs: Vec<String>,
28168    csp_nonce: String,
28169    server_mode: bool,
28170}
28171
28172// ── CompareSelectTemplate ──────────────────────────────────────────────────────
28173
28174#[derive(Template)]
28175#[template(
28176    source = r##"
28177<!doctype html>
28178<html lang="en">
28179<head>
28180  <meta charset="utf-8">
28181  <meta name="viewport" content="width=device-width, initial-scale=1">
28182  <title>OxideSLOC | Compare Scans</title>
28183  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28184  <style nonce="{{ csp_nonce }}">
28185    :root {
28186      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28187      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28188      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28189      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28190      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28191    }
28192    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28193    *{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;}
28194    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28195    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28196    .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);}
28197    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28198    .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));}
28199    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28200    .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;}
28201    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28202    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28203    @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; } }
28204    .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;}
28205    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28206    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28207    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28208    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28209    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28210    .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;}
28211    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28212    .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);}
28213    .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;}
28214    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28215    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28216    .settings-modal-body{padding:14px 16px 16px;}
28217    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28218    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28219    .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;}
28220    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28221    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28222    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28223    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28224    .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;}
28225    .tz-select:focus{border-color:var(--oxide);}
28226    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28227    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28228    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28229    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28230    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28231    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28232    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28233    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28234    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28235    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28236    .per-page-label{font-size:13px;color:var(--muted);}
28237    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;}
28238    .filter-input{min-width:180px;cursor:text;}
28239    .table-wrap{width:100%;overflow-x:auto;}
28240    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28241    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;}
28242    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28243    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28244    #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;}
28245    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28246    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28247    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28248    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28249    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28250    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28251    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28252    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28253    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28254    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28255    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28256    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28257    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28258    tr:last-child td{border-bottom:none;}
28259    tr.selected td{background:var(--sel-bg);}
28260    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28261    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28262    tr{cursor:pointer;}
28263    tr.row-locked{opacity:.35;cursor:not-allowed;}
28264    tr.row-locked td{pointer-events:none;}
28265    .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;}
28266    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28267    .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;}
28268    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28269    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28270    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28271    .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);}
28272    .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);}
28273    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28274    .metric-num{font-weight:700;color:var(--text);}
28275    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28276    .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;}
28277    .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;}
28278    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28279    .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;}
28280    .btn:hover{background:var(--line);}
28281    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28282    .btn.primary:hover{opacity:.9;}
28283    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28284    .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;}
28285    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28286    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28287    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28288    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28289    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28290    .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;}
28291    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28292    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28293    .watched-chip-rm:hover{color:var(--oxide);}
28294    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28295    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28296    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28297    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28298    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28299    .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;}
28300    .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;}
28301    .btn-back:hover{background:var(--line);}
28302    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28303    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28304    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28305    .pagination-info{font-size:13px;color:var(--muted);}
28306    .pagination-btns{display:flex;gap:6px;}
28307    .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;}
28308    .pg-btn:hover:not(:disabled){background:var(--line);}
28309    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28310    .pg-btn:disabled{opacity:.35;cursor:default;}
28311    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28312    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28313    .site-footer a{color:var(--muted);}
28314    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28315    .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;}
28316    .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;}
28317    .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;}
28318    @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));}}
28319    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28320    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28321    .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);}
28322    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28323    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28324    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28325    .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);}
28326    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28327    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28328    .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;}
28329    .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;}
28330    .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%;}
28331    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28332    .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;}
28333    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28334    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28335    .hidden{display:none!important;}
28336    .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%;}
28337    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28338    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28339    .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;}
28340    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28341    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28342    .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;}
28343    .scope-option:hover{background:var(--line);}
28344    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28345    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28346    .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;}
28347    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28348    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28349    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28350    .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;}
28351  </style>
28352</head>
28353<body>
28354  <div class="background-watermarks" aria-hidden="true">
28355    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28356    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28357    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28358    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28359    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28360    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28361  </div>
28362  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28363  <div class="top-nav">
28364    <div class="top-nav-inner">
28365      <a class="brand" href="/">
28366        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28367        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28368      </a>
28369      <div class="nav-right">
28370        <a class="nav-pill" href="/">Home</a>
28371        <div class="nav-dropdown">
28372          <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>
28373          <div class="nav-dropdown-menu">
28374            <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>
28375          </div>
28376        </div>
28377        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28378        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28379        <div class="nav-dropdown">
28380          <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>
28381          <div class="nav-dropdown-menu">
28382            <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>
28383          </div>
28384        </div>
28385        <div class="server-status-wrap" id="server-status-wrap">
28386          <div class="nav-pill server-online-pill" id="server-status-pill">
28387            <span class="status-dot" id="status-dot"></span>
28388            <span id="server-status-label">Server</span>
28389            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28390          </div>
28391          <div class="server-status-tip">
28392            OxideSLOC is running — accessible on your network.
28393            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28394          </div>
28395        </div>
28396        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28397          <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>
28398        </button>
28399        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28400          <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>
28401          <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>
28402        </button>
28403      </div>
28404    </div>
28405  </div>
28406
28407  <div class="page">
28408    <div class="watched-bar">
28409      <div class="watched-bar-left">
28410        <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>
28411        <span class="watched-label">Watched Folders</span>
28412        <div class="watched-chips">
28413          {% if server_mode %}
28414          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28415          {% else %}
28416          {% for dir in watched_dirs %}
28417          <span class="watched-chip">
28418            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28419            <form method="POST" action="/watched-dirs/remove" style="display:contents">
28420              <input type="hidden" name="folder_path" value="{{ dir }}">
28421              <input type="hidden" name="redirect_to" value="/compare-scans">
28422              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
28423            </form>
28424          </span>
28425          {% endfor %}
28426          {% if watched_dirs.is_empty() %}
28427          <span class="watched-none">No folders watched — click Choose to add one</span>
28428          {% endif %}
28429          {% endif %}
28430        </div>
28431      </div>
28432      {% if !server_mode %}
28433      <div class="watched-bar-right">
28434        <button type="button" class="btn" id="add-watched-btn">
28435          <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>
28436          Choose
28437        </button>
28438        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28439          <input type="hidden" name="redirect_to" value="/compare-scans">
28440          <button type="submit" class="btn">&#8635; Refresh</button>
28441        </form>
28442      </div>
28443      {% endif %}
28444    </div>
28445    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28446      <div class="scan-overlay-card">
28447        <div class="scan-spinner"></div>
28448        <div class="scan-overlay-text">Scanning folder…</div>
28449        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28450      </div>
28451    </div>
28452    <style>
28453    .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);}
28454    .scan-overlay.active{display:flex;}
28455    .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;}
28456    .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;}
28457    @keyframes scanSpin{to{transform:rotate(360deg);}}
28458    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28459    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28460    </style>
28461    {% if total_scans > 0 %}
28462    <div class="summary-strip">
28463      <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>
28464      <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>
28465      <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>
28466      <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>
28467    </div>
28468    {% endif %}
28469    <section class="panel">
28470      <div class="panel-header">
28471        <div>
28472          <h1>Compare Scans</h1>
28473          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28474        </div>
28475        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28476          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28477            <button class="btn primary" id="compare-btn" disabled>
28478              <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>
28479              Compare <span class="sel-count" id="sel-count">0</span> Selected
28480            </button>
28481          </div>
28482        </div>
28483      </div>
28484
28485      {% if entries.is_empty() %}
28486      <div class="empty-state">
28487        <strong>No scans yet</strong>
28488        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.
28489      </div>
28490      {% else %}
28491      <div class="filter-row">
28492        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
28493        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28494        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
28495      </div>
28496      <div class="scope-panel hidden" id="scope-panel">
28497        <div class="scope-panel-label">
28498          <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>
28499          Compare scope — choose what to include
28500        </div>
28501        <div class="scope-options" id="scope-options"></div>
28502      </div>
28503      {% if total_scans > 0 %}
28504      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28505        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28506          <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>
28507          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28508        </div>
28509      </div>
28510      {% endif %}
28511      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28512        <span class="compare-all-label">
28513          <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>
28514          Quick Compare All
28515        </span>
28516      </div>
28517      <div class="table-wrap">
28518        <table id="compare-table">
28519          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28520          <thead>
28521            <tr id="compare-thead">
28522              <th><div class="col-resize-handle"></div></th>
28523              <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>
28524              <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>
28525              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28526              <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>
28527              <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>
28528              <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>
28529              <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>
28530              <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>
28531              <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>
28532              <th>Submodules<div class="col-resize-handle"></div></th>
28533            </tr>
28534          </thead>
28535          <tbody id="compare-tbody">
28536            {% for entry in entries %}
28537            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28538                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28539                data-project="{{ entry.project_label }}"
28540                data-files="{{ entry.files_analyzed }}"
28541                data-code="{{ entry.code_lines }}"
28542                data-comments="{{ entry.comment_lines }}"
28543                data-blank="{{ entry.blank_lines }}"
28544                data-branch="{{ entry.git_branch }}"
28545                data-commit="{{ entry.git_commit }}"
28546                data-submodules="{{ entry.submodule_names_csv }}">
28547              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28548              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28549              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28550              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28551              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28552              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28553              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28554              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28555              <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>
28556              <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>
28557              <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>
28558            </tr>
28559            {% endfor %}
28560          </tbody>
28561        </table>
28562      </div>
28563      <div class="pagination">
28564        <span class="pagination-info" id="pagination-info"></span>
28565        <div class="pagination-btns" id="pagination-btns"></div>
28566        <div class="flex-row">
28567          <span class="per-page-label">Show</span>
28568          <select class="per-page" id="per-page-sel">
28569            <option value="10">10 per page</option>
28570            <option value="25" selected>25 per page</option>
28571            <option value="50">50 per page</option>
28572            <option value="100">100 per page</option>
28573          </select>
28574          <span class="per-page-label" id="page-range-label"></span>
28575        </div>
28576      </div>
28577      {% endif %}
28578    </section>
28579  </div>
28580
28581  <footer class="site-footer">
28582    local code analysis - metrics, history and reports
28583    &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>
28584    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28585    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28586    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28587    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28588  </footer>
28589
28590  <script nonce="{{ csp_nonce }}">
28591    (function () {
28592      // ── Theme ──────────────────────────────────────────────────────────────
28593      var storageKey = 'oxide-sloc-theme';
28594      var body = document.body;
28595      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28596      var toggle = document.getElementById('theme-toggle');
28597      if (toggle) toggle.addEventListener('click', function () {
28598        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28599        body.classList.toggle('dark-theme', next === 'dark');
28600        try { localStorage.setItem(storageKey, next); } catch(e) {}
28601      });
28602
28603      // ── State ─────────────────────────────────────────────────────────────
28604      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28605      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28606      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28607      window._allCompareRows = allRows;
28608
28609      // ── Stat chips ────────────────────────────────────────────────────────
28610      (function() {
28611        var projects = {}, latestTs = '', latestRow = null;
28612        allRows.forEach(function(r) {
28613          var p = r.dataset.project || ''; if (p) projects[p] = true;
28614          var ts = r.dataset.timestamp || '';
28615          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28616        });
28617        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();}
28618        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>':'');}
28619        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28620        if (latestRow) {
28621          setChipVal('agg-code', latestRow.dataset.code);
28622          setChipVal('agg-files', latestRow.dataset.files);
28623        }
28624        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(); });
28625      })();
28626
28627      // ── Branch filter population ──────────────────────────────────────────
28628      (function() {
28629        var branches = {};
28630        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28631        var sel = document.getElementById('branch-filter');
28632        if (sel) Object.keys(branches).sort().forEach(function(b) {
28633          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28634        });
28635      })();
28636
28637      // ── Filter ────────────────────────────────────────────────────────────
28638      function getFilteredRows() {
28639        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28640        var branch = ((document.getElementById('branch-filter') || {}).value || '');
28641        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28642          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28643          if (branch && (r.dataset.branch || '') !== branch) return false;
28644          return true;
28645        });
28646      }
28647
28648      // ── Pagination ────────────────────────────────────────────────────────
28649      function renderPage() {
28650        var filtered = getFilteredRows();
28651        var total = filtered.length;
28652        var totalPages = Math.max(1, Math.ceil(total / perPage));
28653        currentPage = Math.min(currentPage, totalPages);
28654        var start = (currentPage - 1) * perPage;
28655        var end = Math.min(start + perPage, total);
28656        var shown = {};
28657        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28658        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28659          r.style.display = shown[r.dataset.run] ? '' : 'none';
28660        });
28661        var rl = document.getElementById('page-range-label');
28662        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28663        var info = document.getElementById('pagination-info');
28664        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28665        var btns = document.getElementById('pagination-btns');
28666        if (!btns) return;
28667        btns.innerHTML = '';
28668        function makeBtn(lbl, pg, active, disabled) {
28669          var b = document.createElement('button');
28670          b.className = 'pg-btn' + (active ? ' active' : '');
28671          b.textContent = lbl; b.disabled = disabled;
28672          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28673          return b;
28674        }
28675        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28676        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28677        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28678        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28679      }
28680
28681      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28682      window.applyFilters = function() { currentPage = 1; renderPage(); };
28683
28684      // ── Sorting ───────────────────────────────────────────────────────────
28685      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28686      function doSort(col, type, order) {
28687        var tbody = document.getElementById('compare-tbody');
28688        if (!tbody) return;
28689        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28690        rows.sort(function(a, b) {
28691          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28692          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28693          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28694          return va < vb ? 1 : va > vb ? -1 : 0;
28695        });
28696        rows.forEach(function(r) { tbody.appendChild(r); });
28697        currentPage = 1; renderPage();
28698      }
28699      sortHeaders.forEach(function(th) {
28700        th.addEventListener('click', function(e) {
28701          if (e.target.classList.contains('col-resize-handle')) return;
28702          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28703          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28704          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28705          th.classList.add('sort-' + sortOrder);
28706          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28707          doSort(col, type, sortOrder);
28708        });
28709      });
28710
28711      // Apply default sort (timestamp desc) on initial load
28712      (function() {
28713        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28714        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28715      })();
28716
28717      // ── Column resize ─────────────────────────────────────────────────────
28718      (function() {
28719        var table = document.getElementById('compare-table');
28720        if (!table) return;
28721        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28722        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28723        ths.forEach(function(th, i) {
28724          var handle = th.querySelector('.col-resize-handle');
28725          if (!handle || !cols[i]) return;
28726          var startX, startW;
28727          handle.addEventListener('mousedown', function(e) {
28728            e.stopPropagation(); e.preventDefault();
28729            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28730            handle.classList.add('dragging');
28731            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28732            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28733            document.addEventListener('mousemove', onMove);
28734            document.addEventListener('mouseup', onUp);
28735          });
28736        });
28737      })();
28738
28739      // ── Full-commit hover tooltip ─────────────────────────────────────────
28740      // The commit chips live inside an overflow:auto table wrapper, which would
28741      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28742      // (escaping the scroll container) and follow the cursor. Event delegation
28743      // keeps it working after pagination/sorting re-renders the rows.
28744      (function() {
28745        var tip = document.createElement('div');
28746        tip.className = 'commit-tip';
28747        tip.setAttribute('role', 'tooltip');
28748        document.body.appendChild(tip);
28749        var shown = false;
28750        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28751        function place(e) {
28752          var pad = 14, r = tip.getBoundingClientRect();
28753          var x = e.clientX + pad, y = e.clientY + pad;
28754          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28755          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28756          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28757        }
28758        function hide() { tip.style.display = 'none'; shown = false; }
28759        document.addEventListener('mouseover', function(e) {
28760          var chip = chipFrom(e.target);
28761          if (!chip) return;
28762          var full = chip.getAttribute('data-full-commit');
28763          if (!full) return;
28764          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28765        });
28766        document.addEventListener('mousemove', function(e) {
28767          if (!shown) return;
28768          if (chipFrom(e.target)) place(e); else hide();
28769        });
28770        document.addEventListener('mouseout', function(e) {
28771          if (chipFrom(e.target)) hide();
28772        });
28773      })();
28774
28775      // ── Reset view ────────────────────────────────────────────────────────
28776      window.resetView = function() {
28777        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28778        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28779        sortCol = null; sortOrder = 'asc';
28780        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28781        var tbody = document.getElementById('compare-tbody');
28782        if (tbody) {
28783          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28784          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28785          rows.forEach(function(r) { tbody.appendChild(r); });
28786        }
28787        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28788        var table = document.getElementById('compare-table');
28789        currentPage = 1; renderPage();
28790        currentPage = 1; renderPage();
28791      };
28792
28793      renderPage();
28794      buildCompareAllBar();
28795
28796      // ── Row selection state ───────────────────────────────────────────────
28797      var selected = [];
28798      var lockedProject = null; // project label of first selected scan
28799
28800      function updateCompareBtn() {
28801        var btn = document.getElementById('compare-btn');
28802        var cnt = document.getElementById('sel-count');
28803        if (!btn) return;
28804        btn.disabled = selected.length < 2;
28805        if (cnt) cnt.textContent = selected.length;
28806      }
28807
28808      function applyProjectLock() {
28809        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28810        allRows.forEach(function(r) {
28811          if (lockedProject === null) {
28812            r.classList.remove('row-locked');
28813          } else {
28814            var proj = r.dataset.project || '';
28815            if (proj !== lockedProject) {
28816              r.classList.add('row-locked');
28817            } else {
28818              r.classList.remove('row-locked');
28819            }
28820          }
28821        });
28822      }
28823
28824      function toggleRow(row) {
28825        if (row.classList.contains('row-locked')) return;
28826        var vid = row.dataset.vid || row.dataset.run;
28827        var idx = selected.indexOf(vid);
28828        if (idx >= 0) {
28829          selected.splice(idx, 1);
28830          row.classList.remove('selected');
28831          var b = document.getElementById('badge-' + vid);
28832          if (b) b.textContent = '';
28833          // Release project lock if nothing selected
28834          if (selected.length === 0) lockedProject = null;
28835        } else {
28836          // Set project lock on first selection
28837          if (selected.length === 0) lockedProject = row.dataset.project || null;
28838          selected.push(vid);
28839          row.classList.add('selected');
28840        }
28841        selected.forEach(function(v, i) {
28842          var b = document.getElementById('badge-' + v);
28843          if (b) b.textContent = i + 1;
28844        });
28845        applyProjectLock();
28846        updateCompareBtn();
28847        buildScopePanel();
28848      }
28849
28850      // ── Compare-All bar ───────────────────────────────────────────────────
28851      function buildCompareAllBar() {
28852        var bar = document.getElementById('compare-all-bar');
28853        if (!bar) return;
28854        // Group all rows by project label.
28855        var groups = {};
28856        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28857        // Use all rows from the source data (not just visible).
28858        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28859        // We need ALL rows across all pages, not just the rendered ones.
28860        // Use the underlying allRows array that the pagination JS also uses.
28861        var sourceRows = window._allCompareRows || allRowsAll;
28862        sourceRows.forEach(function(r) {
28863          var proj = r.dataset.project || '';
28864          var vid = r.dataset.vid || r.dataset.run || '';
28865          if (!proj || !vid) return;
28866          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28867          groups[proj].ids.push(vid);
28868          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28869        });
28870        // Build buttons for each project with >= 2 scans.
28871        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28872        if (!keys.length) { bar.style.display = 'none'; return; }
28873        bar.style.display = 'flex';
28874        // Remove old buttons (keep label).
28875        var oldBtns = bar.querySelectorAll('.compare-all-btn');
28876        oldBtns.forEach(function(b) { b.remove(); });
28877        keys.sort();
28878        keys.forEach(function(proj) {
28879          var g = groups[proj];
28880          var btn = document.createElement('button');
28881          btn.className = 'compare-all-btn';
28882          btn.type = 'button';
28883          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28884          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28885          btn.addEventListener('click', function() {
28886            // Sort ids by timestamp (ascending).
28887            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28888            pairs.sort(function(a, b) { return a.ts - b.ts; });
28889            var sorted = pairs.map(function(p) { return p.id; });
28890            if (sorted.length === 2) {
28891              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28892            } else {
28893              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28894            }
28895          });
28896          bar.appendChild(btn);
28897        });
28898      }
28899
28900      // ── Scope panel ───────────────────────────────────────────────────────
28901      var selectedScope = 'all';
28902
28903      function buildScopePanel() {
28904        var panel = document.getElementById('scope-panel');
28905        var opts = document.getElementById('scope-options');
28906        if (!panel || !opts) return;
28907        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28908
28909        // Collect union of submodules from all selected rows.
28910        var allSubs = {};
28911        selected.forEach(function(vid) {
28912          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28913          if (!row) return;
28914          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28915        });
28916        var subList = Object.keys(allSubs).sort();
28917        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28918
28919        panel.classList.remove('hidden');
28920        opts.innerHTML = '';
28921
28922        function makeOption(value, label, title) {
28923          var div = document.createElement('div');
28924          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28925          div.dataset.scopeValue = value;
28926          if (title) div.title = title;
28927          var radio = document.createElement('span');
28928          radio.className = 'scope-option-radio';
28929          var lbl = document.createElement('span');
28930          lbl.textContent = label;
28931          div.appendChild(radio);
28932          div.appendChild(lbl);
28933          div.addEventListener('click', function() {
28934            selectedScope = value;
28935            opts.querySelectorAll('.scope-option').forEach(function(o) {
28936              o.classList.toggle('selected', o.dataset.scopeValue === value);
28937            });
28938          });
28939          return div;
28940        }
28941
28942        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28943        var sep = document.createElement('span');
28944        sep.className = 'scope-option-sep';
28945        opts.appendChild(sep);
28946        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28947        subList.forEach(function(s) {
28948          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28949        });
28950      }
28951
28952      function doCompare() {
28953        if (selected.length < 2) return;
28954        if (selected.length === 2) {
28955          // Two-scan delta (existing flow with scope support).
28956          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28957          if (selectedScope === 'super') url += '&scope=super';
28958          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28959          window.location.href = url;
28960        } else {
28961          // Multi-scan timeline (N >= 3) — pass scope params too.
28962          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28963          if (selectedScope === 'super') url += '&scope=super';
28964          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28965          window.location.href = url;
28966        }
28967      }
28968
28969      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28970      var cbtn = document.getElementById('compare-btn');
28971      if (cbtn) cbtn.addEventListener('click', doCompare);
28972      var pfEl = document.getElementById('project-filter');
28973      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28974      var bfEl = document.getElementById('branch-filter');
28975      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28976      var rvBtn = document.getElementById('reset-view-btn');
28977      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28978      var ppSel = document.getElementById('per-page-sel');
28979      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28980
28981      var cmpTbody = document.getElementById('compare-tbody');
28982      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28983        var row = e.target.closest('.compare-row');
28984        if (row) toggleRow(row);
28985      });
28986
28987      (function randomizeWatermarks() {
28988        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28989        if (!wms.length) return;
28990        var placed = [];
28991        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;}
28992        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];}
28993        var half=Math.floor(wms.length/2);
28994        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;});
28995      })();
28996
28997      (function spawnCodeParticles() {
28998        var container = document.getElementById('code-particles');
28999        if (!container) return;
29000        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'];
29001        for (var i = 0; i < 38; i++) {
29002          (function(idx) {
29003            var el = document.createElement('span');
29004            el.className = 'code-particle';
29005            el.textContent = snippets[idx % snippets.length];
29006            var left = Math.random() * 94 + 2;
29007            var top = Math.random() * 88 + 6;
29008            var dur = (Math.random() * 10 + 9).toFixed(1);
29009            var delay = (Math.random() * 18).toFixed(1);
29010            var rot = (Math.random() * 26 - 13).toFixed(1);
29011            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29012            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';
29013            container.appendChild(el);
29014          })(i);
29015        }
29016      })();
29017
29018      // ── Watched folder picker ─────────────────────────────────────────────
29019      (function(){
29020        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');};
29021        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);
29022      })();
29023      (function() {
29024        var btn = document.getElementById('add-watched-btn');
29025        if (!btn) return;
29026        btn.addEventListener('click', function() {
29027          fetch('/pick-directory?kind=reports')
29028            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29029            .then(function(data) {
29030              if (!data.cancelled && data.selected_path) {
29031                var form = document.createElement('form');
29032                form.method = 'POST';
29033                form.action = '/watched-dirs/add';
29034                var ri = document.createElement('input');
29035                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29036                var fi = document.createElement('input');
29037                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29038                form.appendChild(ri); form.appendChild(fi);
29039                document.body.appendChild(form);
29040                if (window.__scanOverlay) window.__scanOverlay();
29041                form.submit();
29042              }
29043            })
29044            .catch(function(e) { alert('Could not open folder picker: ' + e); });
29045        });
29046      })();
29047
29048      // ── Submodule chip truncation ─────────────────────────────────────────
29049      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29050        var chips = cell.querySelectorAll('.submod-chip');
29051        var MAX = 4;
29052        if (chips.length <= MAX) return;
29053        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29054        var badge = document.createElement('span');
29055        badge.className = 'submod-overflow-badge';
29056        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29057        badge.textContent = '+' + (chips.length - MAX) + ' more';
29058        cell.appendChild(badge);
29059        cell.style.maxHeight = 'none';
29060      });
29061    })();
29062  </script>
29063  <script nonce="{{ csp_nonce }}">
29064  (function(){
29065    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'}];
29066    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);});}
29067    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29068    function init(){
29069      var btn=document.getElementById('settings-btn');if(!btn)return;
29070      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29071      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>';
29072      document.body.appendChild(m);
29073      var g=document.getElementById('scheme-grid');
29074      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);});
29075      var cl=document.getElementById('settings-close');
29076      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);});})();
29077      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');});
29078      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29079      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29080    }
29081    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29082  }());
29083  </script>
29084  <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]';
29085  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;}
29086  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>
29087</body>
29088</html>
29089"##,
29090    ext = "html"
29091)]
29092struct CompareSelectTemplate {
29093    version: &'static str,
29094    entries: Vec<HistoryEntryRow>,
29095    total_scans: usize,
29096    watched_dirs: Vec<String>,
29097    csp_nonce: String,
29098    server_mode: bool,
29099}
29100
29101// ── CompareTemplate ────────────────────────────────────────────────────────────
29102
29103#[derive(Template)]
29104#[template(
29105    source = r##"
29106<!doctype html>
29107<html lang="en">
29108<head>
29109  <meta charset="utf-8">
29110  <meta name="viewport" content="width=device-width, initial-scale=1">
29111  <title>OxideSLOC | Scan Delta</title>
29112  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29113  <style nonce="{{ csp_nonce }}">
29114    :root {
29115      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29116      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29117      --nav:#283790; --nav-2:#013e6b;
29118      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29119      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29120      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29121    }
29122    body.dark-theme {
29123      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29124      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29125    }
29126    *{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;}
29127    .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);}
29128    .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;}
29129    .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));}
29130    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29131    .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;}
29132    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29133    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29134    @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; } }
29135    .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;}
29136    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29137    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29138    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29139    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29140    .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;}
29141    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29142    .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);}
29143    .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;}
29144    .settings-close:hover{color:var(--text);background:var(--surface-2);}
29145    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29146    .settings-modal-body{padding:14px 16px 16px;}
29147    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29148    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29149    .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;}
29150    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29151    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29152    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29153    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29154    .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;}
29155    .tz-select:focus{border-color:var(--oxide);}
29156    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29157    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29158    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29159    .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;}
29160    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29161    .hero-body{display:block;}
29162    .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;}
29163    .btn-back:hover{background:var(--line);}
29164    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29165    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29166    .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;}
29167    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29168    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;}
29169    .muted{color:var(--muted);font-size:14px;}
29170    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29171    .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;}
29172    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29173    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29174    .vpill-arrow{font-size:20px;color:var(--muted);}
29175    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29176    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29177    .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;}
29178    .delta-card.delta-card-wide{padding:22px 24px;}
29179    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29180    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29181    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29182    .delta-card-from{font-size:15px;color:var(--muted);}
29183    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29184    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29185    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29186    .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%;}
29187    .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;}
29188    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29189    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29190    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29191    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29192    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29193    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29194    .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;}
29195    .meta-card-commit:hover{color:var(--oxide);}
29196    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29197    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29198    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29199    .meta-value{color:var(--text);font-size:13px;}
29200    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29201    .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;}
29202    .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);}
29203    .delta-card:hover .dc-tip{display:block;}
29204    .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;}
29205    .export-btn:hover{background:var(--line);}
29206    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29207    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29208    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29209    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29210    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29211    .delta-card-change.zero{color:var(--muted);background:transparent;}
29212    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29213    .delta-card-pct.pos{color:var(--pos);}
29214    .delta-card-pct.neg{color:var(--neg);}
29215    .delta-card-pct.zero{color:var(--muted);}
29216    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29217    .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;}
29218    .insight-card.insight-flag{border-color:var(--oxide);}
29219    .insight-card:hover .dc-tip{display:block;}
29220    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29221    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29222    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29223    .insight-label.flag{color:var(--oxide);}
29224    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29225    .insight-val.pos{color:var(--pos);}
29226    .insight-val.neg{color:var(--neg);}
29227    .insight-val.high{color:#c0392a;}
29228    .insight-val.med{color:#926000;}
29229    .insight-val.low{color:var(--pos);}
29230    body.dark-theme .insight-val.high{color:#ff6b6b;}
29231    body.dark-theme .insight-val.med{color:#f0c060;}
29232    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29233    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29234    .fc-row{display:flex;align-items:center;gap:8px;}
29235    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29236    .fc-label{color:var(--muted);}
29237    .fc-modified .fc-count{color:#926000;}
29238    .fc-added .fc-count{color:var(--pos);}
29239    .fc-removed .fc-count{color:var(--neg);}
29240    .fc-unchanged .fc-count{color:var(--muted);}
29241    .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29242    .fc-total .fc-count{color:var(--text);}
29243    .fc-total .fc-label{font-weight:700;}
29244    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29245    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29246    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29247    .chip.modified{background:#fff2d8;color:#926000;}
29248    .chip.added{background:#e8f5ed;color:#1a8f47;}
29249    .chip.removed{background:#fdeaea;color:#b33b3b;}
29250    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29251    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29252    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29253    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29254    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29255    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29256    .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;}
29257    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29258    .tab-btn:hover:not(.active){background:var(--line);}
29259    .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;}
29260    .btn-reset:hover{background:var(--line);}
29261    .table-wrap{width:100%;overflow-x:auto;}
29262    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29263    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);}
29264    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29265    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29266    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29267    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29268    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29269    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29270    tr:last-child td{border-bottom:none;}
29271    tr:hover td{background:var(--surface-2);}
29272    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29273    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29274    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29275    /* Fixed layout: column widths come from the colgroup, not from scanning every
29276       row. With auto layout a large file matrix forces the browser to re-measure
29277       all cells on each reflow, which freezes the page during sort/resize. */
29278    #delta-table{table-layout:fixed;}
29279    #delta-table col:nth-child(1){width:32%;}
29280    #delta-table col:nth-child(2){width:11%;}
29281    #delta-table col:nth-child(3){width:11%;}
29282    #delta-table col:nth-child(4){width:16%;}
29283    #delta-table col:nth-child(5){width:10%;}
29284    #delta-table col:nth-child(6){width:10%;}
29285    #delta-table col:nth-child(7){width:10%;}
29286    tr.row-added td{background:rgba(26,143,71,0.04);}
29287    tr.row-removed td{background:rgba(179,59,59,0.06);}
29288    tr.row-modified td{background:rgba(146,96,0,0.04);}
29289    tr.row-unchanged td{color:var(--muted);}
29290    tr.row-unchanged .status-badge{opacity:.65;}
29291    .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;}
29292    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29293    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29294    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29295    .status-badge.modified{background:#fff2d8;color:#926000;}
29296    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29297    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29298    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29299    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29300    .delta-val{font-weight:700;}
29301    .delta-val.pos{color:var(--pos);}
29302    .delta-val.neg{color:var(--neg);}
29303    .delta-val.zero{color:var(--muted);}
29304    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29305    .from-to strong{color:var(--text);font-weight:700;}
29306    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29307    .from-to .ft-absent{color:var(--muted);font-weight:600;}
29308    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29309    .site-footer a{color:var(--muted);}
29310    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;}
29311    body.pdf-mode{background:#fff!important;}
29312    body.pdf-mode .page{padding:4px 6px 4px!important;}
29313    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29314    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29315    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29316    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29317    .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;}
29318    .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;}
29319    .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;}
29320    @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));}}
29321    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29322    .path-link:hover{color:var(--oxide-2);}
29323    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29324    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29325    a.vpill-id:hover{color:var(--oxide);}
29326    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29327    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29328    .pagination-info{font-size:13px;color:var(--muted);}
29329    .pagination-btns{display:flex;gap:6px;}
29330    .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;}
29331    .pg-btn:hover:not(:disabled){background:var(--line);}
29332    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29333    .pg-btn:disabled{opacity:.35;cursor:default;}
29334    .per-page-label{font-size:13px;color:var(--muted);}
29335    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;}
29336    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29337    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29338    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29339    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29340    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29341    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29342    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29343    .tab-btn.tab-unchanged{color:var(--muted);}
29344    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29345    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29346    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29347    .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;}
29348    .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;}
29349    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29350    .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;}
29351    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29352    .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;}
29353    .submod-scope-btn:hover{background:var(--line);}
29354    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29355    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29356    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29357    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29358    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29359    body.dark-theme .ic-card{background:var(--surface-2);}
29360    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29361    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29362    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29363    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29364    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29365    .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);}
29366    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29367    .ic-card-h2-row .ic-card-h2{margin:0;}
29368    .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;}
29369    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29370    .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;}
29371    .ic-svg-modal-ov.open{display:flex;}
29372    .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);}
29373    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29374    .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);}
29375    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29376    .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;}
29377    .ic-svg-modal-close:hover{background:var(--line);}
29378    .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;}
29379    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29380    .chart-metric-btn:hover:not(.active){background:var(--line);}
29381    .chart-wrap{width:100%;overflow-x:auto;}
29382    #cmp-tl-svg{display:block;width:100%;}
29383    .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);}
29384    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29385    #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;}
29386  </style>
29387</head>
29388<body>
29389  {{ loading_overlay|safe }}
29390  <div class="background-watermarks" aria-hidden="true">
29391    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29392    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29393    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29394    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29395    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29396    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29397  </div>
29398  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29399  <div class="top-nav">
29400    <div class="top-nav-inner">
29401      <a class="brand" href="/">
29402        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29403        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29404      </a>
29405      <div class="nav-right">
29406        <a class="nav-pill" href="/">Home</a>
29407        <div class="nav-dropdown">
29408          <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>
29409          <div class="nav-dropdown-menu">
29410            <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>
29411          </div>
29412        </div>
29413        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29414        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29415        <div class="nav-dropdown">
29416          <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>
29417          <div class="nav-dropdown-menu">
29418            <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>
29419          </div>
29420        </div>
29421        <div class="server-status-wrap" id="server-status-wrap">
29422          <div class="nav-pill server-online-pill" id="server-status-pill">
29423            <span class="status-dot" id="status-dot"></span>
29424            <span id="server-status-label">Server</span>
29425            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29426          </div>
29427          <div class="server-status-tip">
29428            OxideSLOC is running — accessible on your network.
29429            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29430          </div>
29431        </div>
29432        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29433          <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>
29434        </button>
29435        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29436          <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>
29437          <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>
29438        </button>
29439      </div>
29440    </div>
29441  </div>
29442
29443  <div class="page">
29444    <section class="hero">
29445      <div class="hero-header">
29446        <div>
29447          <h1 class="delta-title">Scan Delta</h1>
29448          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29449          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29450            {% if let Some(sub) = active_submodule %}
29451            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29452            {% else if super_scope_active %}
29453            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29454            {% else %}
29455            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29456            {% endif %}
29457            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29458          </div>
29459        </div>
29460        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29461          <a class="btn-back" href="/compare-scans">
29462            <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>
29463            Compare Scans
29464          </a>
29465          <div class="export-group" style="margin-top:12px;">
29466            <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>
29467            <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>
29468          </div>
29469        </div>
29470      </div>
29471      {% if has_any_submodule_data %}
29472      <div class="submod-scope-bar">
29473        <span class="submod-scope-label">
29474          <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>
29475          Scope:
29476        </span>
29477        <div class="submod-scope-divider"></div>
29478        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29479           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
29480           title="All files — super-repo and all submodules combined">Full scan</a>
29481        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29482           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
29483           title="Only files that are not part of any submodule">Super-repo only</a>
29484        {% for sub in submodule_options %}
29485        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29486           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
29487           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29488        {% endfor %}
29489      </div>
29490      {% endif %}
29491      <div class="hero-body">
29492      <div class="meta-strip">
29493        <div class="delta-card delta-card-meta">
29494          <div class="meta-card-header">
29495            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29496            <div class="meta-card-project-col">
29497              <div class="meta-card-project">{{ project_name }}</div>
29498              {% if has_any_submodule_data %}
29499              {% if let Some(sub) = active_submodule %}
29500              <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>
29501              {% else if super_scope_active %}
29502              <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>
29503              {% else %}
29504              <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>
29505              {% endif %}
29506              {% endif %}
29507            </div>
29508          </div>
29509          {% if !baseline_git_commit.is_empty() %}
29510          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29511          {% else %}
29512          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29513          {% endif %}
29514          <div class="meta-card-rows">
29515            <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>
29516            <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>
29517            <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>
29518            <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>
29519            {% if let Some(tags) = baseline_git_tags %}
29520            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29521            {% endif %}
29522          </div>
29523        </div>
29524        <div class="delta-card delta-card-meta">
29525          <div class="meta-card-header">
29526            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29527            <div class="meta-card-project-col">
29528              <div class="meta-card-project">{{ project_name }}</div>
29529              {% if has_any_submodule_data %}
29530              {% if let Some(sub) = active_submodule %}
29531              <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>
29532              {% else if super_scope_active %}
29533              <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>
29534              {% else %}
29535              <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>
29536              {% endif %}
29537              {% endif %}
29538            </div>
29539          </div>
29540          {% if !current_git_commit.is_empty() %}
29541          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29542          {% else %}
29543          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29544          {% endif %}
29545          <div class="meta-card-rows">
29546            <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>
29547            <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>
29548            <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>
29549            <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>
29550            {% if let Some(tags) = current_git_tags %}
29551            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29552            {% endif %}
29553          </div>
29554        </div>
29555      </div>
29556      <div class="delta-strip">
29557        <div class="delta-card">
29558          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29559          <div class="delta-card-label">Code lines</div>
29560          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29561          <div class="delta-card-to">{{ current_code_fmt }}</div>
29562          {% 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>
29563          {% 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>
29564          {% else %}<div class="delta-card-pct zero">±0%</div>
29565          {% endif %}
29566        </div>
29567        <div class="delta-card">
29568          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29569          <div class="delta-card-label">Files analyzed</div>
29570          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29571          <div class="delta-card-to">{{ current_files_fmt }}</div>
29572          {% 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>
29573          {% 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>
29574          {% else %}<div class="delta-card-pct zero">±0%</div>
29575          {% endif %}
29576        </div>
29577        <div class="delta-card">
29578          <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>
29579          <div class="delta-card-label">Comment lines</div>
29580          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29581          <div class="delta-card-to">{{ current_comments_fmt }}</div>
29582          {% 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>
29583          {% 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>
29584          {% else %}<div class="delta-card-pct zero">±0%</div>
29585          {% endif %}
29586        </div>
29587        {{ coverage_delta_card|safe }}
29588        <div class="delta-card delta-card-wide">
29589          <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>
29590          <div class="delta-card-label">File changes</div>
29591          <div class="file-changes-grid">
29592            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29593            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29594            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29595            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29596            <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>
29597          </div>
29598        </div>
29599      </div>
29600      <div class="insights-panel">
29601        <div class="insight-card">
29602          <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>
29603          <div class="insight-label">Lines Added</div>
29604          <div class="insight-val pos">+{{ code_lines_added }}</div>
29605          <div class="insight-sub">New or grown source lines</div>
29606        </div>
29607        <div class="insight-card">
29608          <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>
29609          <div class="insight-label">Lines Removed</div>
29610          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
29611          <div class="insight-sub">Deleted or shrunk source lines</div>
29612        </div>
29613        <div class="insight-card">
29614          <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>
29615          <div class="insight-label">Lines Modified</div>
29616          <div class="insight-val">{{ code_lines_modified }}</div>
29617          <div class="insight-sub">Code lines in modified files</div>
29618        </div>
29619        <div class="insight-card">
29620          <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>
29621          <div class="insight-label">Lines Unmodified</div>
29622          <div class="insight-val">{{ code_lines_unmodified }}</div>
29623          <div class="insight-sub">Code lines in unchanged files</div>
29624        </div>
29625        <div class="insight-card">
29626          <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29627          <div class="insight-label">Lines Total</div>
29628          <div class="insight-val">{{ code_lines_total }}</div>
29629          <div class="insight-sub">Added + removed + modified + unmodified</div>
29630        </div>
29631        <div class="insight-card">
29632          <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>
29633          <div class="insight-label">Churn Rate</div>
29634          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29635          <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>
29636        </div>
29637        {% if scope_flag %}
29638        <div class="insight-card insight-flag">
29639          <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>
29640          <div class="insight-label flag">Scope Signal</div>
29641          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29642          <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>
29643        </div>
29644        {% endif %}
29645      </div>
29646      </div>
29647    </section>
29648
29649    <section class="panel" id="inline-charts-section">
29650      <div class="panel-title">Scan Delta Charts</div>
29651      <div class="ic-grid">
29652        <div class="ic-card" style="grid-column:span 2">
29653          <div class="ic-card-h2-row">
29654            <span class="ic-card-h2">Timeline</span>
29655            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29656              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29657              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29658              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29659              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29660              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29661            </div>
29662            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
29663          </div>
29664          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29665        </div>
29666        <div class="ic-card">
29667          <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>
29668          <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>
29669          <div id="ic-c1"></div>
29670        </div>
29671        <div class="ic-card" id="ic-lang-card">
29672          <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>
29673          <div id="ic-c3"></div>
29674        </div>
29675        <div class="ic-card">
29676          <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>
29677          <div id="ic-c2"></div>
29678        </div>
29679        <div class="ic-card">
29680          <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>
29681          <div id="ic-c4"></div>
29682        </div>
29683      </div>
29684      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29685        <div class="ic-svg-modal">
29686          <div class="ic-svg-modal-hdr">
29687            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29688            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
29689          </div>
29690          <div id="ic-svg-modal-body"></div>
29691        </div>
29692      </div>
29693    </section>
29694
29695    <section class="panel">
29696      <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>
29697      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29698        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29699          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29700          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29701          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29702          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29703          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29704        </div>
29705        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29706          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
29707          <div class="export-group">
29708            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
29709            <button type="button" class="export-btn" id="delta-csv-btn">
29710              <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>
29711              CSV
29712            </button>
29713            <button type="button" class="export-btn" id="delta-xls-btn">
29714              <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>
29715              Excel
29716            </button>
29717          </div>
29718        </div>
29719      </div>
29720
29721      <div class="table-wrap">
29722      <table id="delta-table">
29723        <colgroup>
29724          <col>
29725          <col>
29726          <col>
29727          <col>
29728          <col>
29729          <col>
29730          <col>
29731        </colgroup>
29732        <thead>
29733          <tr id="delta-thead">
29734            <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>
29735            <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>
29736            <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>
29737            <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>
29738            <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>
29739            <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>
29740            <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>
29741          </tr>
29742        </thead>
29743        <tbody id="delta-tbody">
29744          {% for row in file_rows %}
29745          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29746              data-path="{{ row.relative_path }}"
29747              data-language="{{ row.language }}"
29748              data-baseline-code="{{ row.baseline_code }}"
29749              data-current-code="{{ row.current_code }}"
29750              data-code-delta="{{ row.code_delta_str }}"
29751              data-comment-delta="{{ row.comment_delta_str }}"
29752              data-total-delta="{{ row.total_delta_str }}"
29753              data-orig-idx="">
29754            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29755            <td class="hide-sm">{{ row.language }}</td>
29756            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29757            <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>
29758            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29759            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29760            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29761          </tr>
29762          {% endfor %}
29763        </tbody>
29764      </table>
29765      </div>
29766      <div class="pagination">
29767        <span class="pagination-info" id="pg-range-label"></span>
29768        <div class="pagination-btns" id="pg-btns"></div>
29769        <div class="flex-row">
29770          <span class="per-page-label">Show</span>
29771          <select class="per-page" id="per-page-sel">
29772            <option value="10">10 per page</option>
29773            <option value="25" selected>25 per page</option>
29774            <option value="50">50 per page</option>
29775            <option value="100">100 per page</option>
29776          </select>
29777        </div>
29778      </div>
29779    </section>
29780  </div>
29781
29782  <div id="ic-tt"></div>
29783
29784  <footer class="site-footer">
29785    local code analysis - metrics, history and reports
29786    &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>
29787    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29788    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29789    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29790    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29791  </footer>
29792
29793  <script nonce="{{ csp_nonce }}">
29794    (function () {
29795      var storageKey = 'oxide-sloc-theme';
29796      var body = document.body;
29797      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29798      var toggle = document.getElementById('theme-toggle');
29799      if (toggle) toggle.addEventListener('click', function () {
29800        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29801        body.classList.toggle('dark-theme', next === 'dark');
29802        try { localStorage.setItem(storageKey, next); } catch(e) {}
29803      });
29804
29805      (function randomizeWatermarks() {
29806        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29807        if (!wms.length) return;
29808        var placed = [];
29809        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;}
29810        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];}
29811        var half=Math.floor(wms.length/2);
29812        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;});
29813      })();
29814
29815      (function spawnCodeParticles() {
29816        var container = document.getElementById('code-particles');
29817        if (!container) return;
29818        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'];
29819        for (var i = 0; i < 38; i++) {
29820          (function(idx) {
29821            var el = document.createElement('span');
29822            el.className = 'code-particle';
29823            el.textContent = snippets[idx % snippets.length];
29824            var left = Math.random() * 94 + 2;
29825            var top = Math.random() * 88 + 6;
29826            var dur = (Math.random() * 10 + 9).toFixed(1);
29827            var delay = (Math.random() * 18).toFixed(1);
29828            var rot = (Math.random() * 26 - 13).toFixed(1);
29829            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29830            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';
29831            container.appendChild(el);
29832          })(i);
29833        }
29834      })();
29835    })();
29836
29837    var activeStatusFilter = 'all';
29838    var deltaPerPage = 25, deltaCurrPage = 1;
29839
29840    function openFolder(path) {
29841      fetch('/open-path?path=' + encodeURIComponent(path))
29842        .then(function (r) { return r.json(); })
29843        .then(function (d) {
29844          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29845        })
29846        .catch(function () {});
29847    }
29848
29849    // \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
29850    // The server renders every row once; we lift them into a plain-data array and
29851    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29852    // filtering run on the array (no DOM churn) and each render rebuilds just one
29853    // page (~25 rows). This keeps every interaction O(page) instead of O(all
29854    // files): a 28k-row table previously re-touched every node on each click
29855    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29856    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29857
29858    function parseDeltaNum(str) {
29859      if (!str || str === '\u2014') return 0;
29860      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29861    }
29862
29863    function captureDelta() {
29864      var tbody = document.getElementById('delta-tbody');
29865      if (!tbody) return;
29866      var rows = tbody.querySelectorAll('.delta-row');
29867      for (var i = 0; i < rows.length; i++) {
29868        var r = rows[i];
29869        DELTA.push({
29870          h: r.innerHTML,
29871          cls: r.className,
29872          path: r.getAttribute('data-path') || '',
29873          lang: r.getAttribute('data-language') || '',
29874          status: r.getAttribute('data-status') || '',
29875          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29876          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29877          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29878          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29879          td: parseDeltaNum(r.getAttribute('data-total-delta')),
29880          bcs: r.getAttribute('data-baseline-code') || '',
29881          ccs: r.getAttribute('data-current-code') || '',
29882          cds: r.getAttribute('data-code-delta') || '',
29883          cmds: r.getAttribute('data-comment-delta') || '',
29884          tds: r.getAttribute('data-total-delta') || ''
29885        });
29886      }
29887      tbody.innerHTML = '';
29888    }
29889
29890    function applyDeltaQuery() {
29891      var v = (activeStatusFilter === 'all') ? DELTA.slice()
29892        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29893      if (sortCol) {
29894        var asc = sortOrder === 'asc';
29895        v.sort(function(a, b) {
29896          var va, vb;
29897          if (sortCol === 'path') { va = a.path; vb = b.path; }
29898          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29899          else if (sortCol === 'status') { va = a.status; vb = b.status; }
29900          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29901          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29902          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29903          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29904          else { return 0; }
29905          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29906          return va < vb ? 1 : va > vb ? -1 : 0;
29907        });
29908      }
29909      _deltaView = v;
29910      deltaCurrPage = 1;
29911      renderDeltaPage();
29912    }
29913
29914    function renderDeltaPage() {
29915      var total = _deltaView.length;
29916      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29917      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29918      if (deltaCurrPage < 1) deltaCurrPage = 1;
29919      var start = (deltaCurrPage - 1) * deltaPerPage;
29920      var end = Math.min(start + deltaPerPage, total);
29921      var tbody = document.getElementById('delta-tbody');
29922      if (tbody) {
29923        var html = '';
29924        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29925        tbody.innerHTML = html;
29926      }
29927      var rl = document.getElementById('pg-range-label');
29928      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29929      var btns = document.getElementById('pg-btns');
29930      if (!btns) return;
29931      btns.innerHTML = '';
29932      if (totalPages <= 1) return;
29933      function makeBtn(lbl, pg, active, disabled) {
29934        var b = document.createElement('button');
29935        b.className = 'pg-btn' + (active ? ' active' : '');
29936        b.textContent = lbl; b.disabled = disabled;
29937        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29938        return b;
29939      }
29940      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29941      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29942      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29943      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29944    }
29945
29946    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29947
29948    function filterRows(status, btn) {
29949      activeStatusFilter = status;
29950      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29951        b.classList.remove('active');
29952      });
29953      if (btn) btn.classList.add('active');
29954      applyDeltaQuery();
29955    }
29956
29957    // ── Sorting ──────────────────────────────────────────────────────────────
29958    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29959    sortHeaders.forEach(function(th) {
29960      th.addEventListener('click', function(e) {
29961        if (e.target.classList.contains('col-resize-handle')) return;
29962        var col = th.dataset.sortCol;
29963        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29964        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29965        th.classList.add('sort-' + sortOrder);
29966        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29967        applyDeltaQuery();
29968      });
29969    });
29970
29971    // ── Column resize ─────────────────────────────────────────────────────────
29972    (function() {
29973      var table = document.getElementById('delta-table');
29974      if (!table) return;
29975      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29976      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29977      ths.forEach(function(th, i) {
29978        var handle = th.querySelector('.col-resize-handle');
29979        if (!handle || !cols[i]) return;
29980        handle.addEventListener('mousedown', function(e) {
29981          e.stopPropagation(); e.preventDefault();
29982          // Lock every column to its current rendered px width and size the table
29983          // to the column total. With table-layout:fixed + width:100% the table is
29984          // pinned to the container, so widening one <col> only rebalances the rest
29985          // and the drag looks inert; pinning px widths lets the column actually
29986          // grow while the wrapper (overflow-x:auto) scrolls.
29987          var startTableW = 0;
29988          for (var k = 0; k < ths.length; k++) {
29989            if (!cols[k]) continue;
29990            var w = ths[k].getBoundingClientRect().width;
29991            cols[k].style.width = w + 'px';
29992            startTableW += w;
29993          }
29994          table.style.width = startTableW + 'px';
29995          var startX = e.clientX;
29996          var startW = ths[i].getBoundingClientRect().width;
29997          handle.classList.add('dragging');
29998          function onMove(ev) {
29999            var newW = Math.max(40, startW + ev.clientX - startX);
30000            cols[i].style.width = newW + 'px';
30001            table.style.width = (startTableW + (newW - startW)) + 'px';
30002          }
30003          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30004          document.addEventListener('mousemove', onMove);
30005          document.addEventListener('mouseup', onUp);
30006        });
30007      });
30008    })();
30009
30010    // ── Reset ─────────────────────────────────────────────────────────────────
30011    window.resetDeltaTable = function() {
30012      sortCol = null; sortOrder = 'asc';
30013      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30014      var table = document.getElementById('delta-table');
30015      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30016      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30017      activeStatusFilter = 'all';
30018      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30019      var allBtn = document.querySelector('.tab-btn');
30020      if (allBtn) allBtn.classList.add('active');
30021      applyDeltaQuery();
30022    };
30023
30024    // Compact number formatter (shared by the delta table; charts define their own locally)
30025    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();}
30026    function fmtFull(n){return Number(n).toLocaleString();}
30027
30028    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30029    function fmtFromTo() {
30030      var tbody = document.getElementById('delta-tbody');
30031      if (!tbody) return;
30032      tbody.querySelectorAll('.delta-row').forEach(function(row) {
30033        var status = row.dataset.status || '';
30034        var ft = row.querySelector('.from-to');
30035        if (!ft) return;
30036        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30037        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30038        var strongs = ft.querySelectorAll('strong');
30039        // Apply fmt() to non-absent strong values
30040        strongs.forEach(function(el) {
30041          var n = parseInt(el.textContent, 10);
30042          if (!isNaN(n)) el.textContent = fmtFull(n);
30043        });
30044        // Safety: force dash for genuinely absent sides
30045        if (status === 'added' && bv === 0) {
30046          var bs = ft.querySelector('strong:first-of-type');
30047          if (bs && bs.textContent === '0') {
30048            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30049          }
30050        }
30051        if (status === 'removed' && cv === 0) {
30052          var cs = ft.querySelector('strong:last-of-type');
30053          if (cs && cs.textContent === '0') {
30054            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30055          }
30056        }
30057      });
30058    }
30059    // Initialize: format the server-rendered rows, lift them into the data model
30060    // (which also clears the DOM), then render only the first page.
30061    fmtFromTo();
30062    captureDelta();
30063    applyDeltaQuery();
30064
30065    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30066    (function() {
30067      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30068        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30069      });
30070      var resetBtn = document.getElementById('delta-reset-btn');
30071      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30072      var csvBtn = document.getElementById('delta-csv-btn');
30073      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30074      var xlsBtn = document.getElementById('delta-xls-btn');
30075      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30076      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30077      function sdFetchUri(path) {
30078        return fetch(path).then(function(r){return r.blob();}).then(function(b){
30079          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30080        }).catch(function(){return '';});
30081      }
30082      function sdInlineImgs(html, cb) {
30083        var paths=[], seen={};
30084        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30085        if(!paths.length){cb(html);return;}
30086        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30087          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30088          .catch(function(){cb(html);});
30089      }
30090      function buildFullPageHtml(pdfMode) {
30091        if(pdfMode) document.body.classList.add('pdf-mode');
30092        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30093        renderDeltaPage();
30094        var html = document.documentElement.outerHTML;
30095        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30096        if(pdfMode) document.body.classList.remove('pdf-mode');
30097        return html;
30098      }
30099      var chartsBtn = document.getElementById('delta-charts-btn');
30100      if (chartsBtn) chartsBtn.addEventListener('click', function() {
30101        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30102        sdInlineImgs(buildFullPageHtml(false), function(html) {
30103          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30104          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30105          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30106          btn.disabled=false;btn.innerHTML=orig;
30107        });
30108      });
30109      var pageHtmlBtn = document.getElementById('page-export-html-btn');
30110      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30111        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30112        sdInlineImgs(buildFullPageHtml(false), function(html) {
30113          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30114          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30115          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30116          btn.disabled=false;btn.innerHTML=orig;
30117        });
30118      });
30119      // PDF export — clean document-style report, not a web page screenshot
30120      function buildDeltaPdfHtml() {
30121        var sd=_sd, dr=getDeltaExportRows();
30122        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30123        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+'%';}
30124        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30125        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30126        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30127        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30128        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30129        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30130        function fmtN(n){return Number(n).toLocaleString();}
30131        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30132        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>';}
30133        var lm={};
30134        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;});
30135        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30136        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30137        // The header/footer flow in normal document order (NOT position:fixed).
30138        // A fixed header repeats on every printed page in Chromium and overlaps
30139        // the content beneath it — silently swallowing the first few table rows of
30140        // pages 2+ and clipping the summary cards on page 1. Letting the header
30141        // flow once at the top and relying on the table's <thead> (which Chromium
30142        // repeats per page) keeps every row visible. `.body` keeps a small inset
30143        // so nothing bleeds to the sheet edge.
30144        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30145          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30146          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30147          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30148          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30149          '.ph-brand em{color:#c45c10;font-style:normal;}'+
30150          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30151          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30152          '.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;}'+
30153          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30154          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30155          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30156          '.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;}'+
30157          '.body{padding:12px 18px 0;}'+
30158          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30159          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30160          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30161          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30162          '.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;}'+
30163          '.meta>div{flex:1 1 0;}'+
30164          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30165          '.sec{margin-bottom:10px;}'+
30166          '.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;}'+
30167          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30168          '.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;}'+
30169          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30170          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30171          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30172          '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;}'+
30173          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30174          'tr:nth-child(even) td{background:#faf8f6;}'+
30175          '.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;}'+
30176          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30177          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30178          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30179          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30180          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30181          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30182          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30183          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30184          '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30185          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30186          '.fcc-n{font-size:18px;font-weight:900;}'+
30187          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30188        var fileRows=dchg.map(function(r){
30189          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30190          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30191            '<td style="'+ss+'">'+esc(st)+'</td>'+
30192            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30193            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30194            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30195        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30196        var more='';
30197        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('');
30198        var extraCards='';
30199        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>';}
30200        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>';}
30201        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30202          '<div class="pdf-header">'+
30203          '<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>'+
30204          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30205          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30206          '</div>'+
30207          '<div class="body">'+
30208          '<div class="sec"><p class="sh">Summary Metrics</p>'+
30209          '<div class="msec">'+
30210          '<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>'+
30211          '<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>'+
30212          '<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>'+
30213          '<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>'+
30214          '<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>'+
30215          '<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>'+
30216          extraCards+'</div></div>'+
30217          '<div class="sec"><p class="sh">File Changes</p>'+
30218          '<div class="fcsec">'+
30219          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30220          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30221          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30222          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30223          '<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>'+
30224          '</div></div>'+
30225          (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>':'')+
30226          '<div class="sec">'+
30227          '<table><thead>'+
30228          '<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>'+
30229          '<tr><th>File</th><th>Language</th><th>Status</th>'+
30230          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30231          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30232          '</div>'+
30233          '<div class="rfoot">'+
30234          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30235          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30236          '</div>'+
30237          '</body></html>';
30238      }
30239      function doDeltaPdf(btn) {
30240        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30241      }
30242      var pdfBtn = document.getElementById('delta-pdf-btn');
30243      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30244      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30245      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30246      if (location.protocol === 'file:') {
30247        [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'; } });
30248        [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'; } });
30249      }
30250      var ppSel = document.getElementById('per-page-sel');
30251      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30252      var pathLink = document.getElementById('project-path-link');
30253      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30254    })();
30255
30256    // ── Export helpers ────────────────────────────────────────────────────────
30257    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
30258    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30259    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);}
30260    function slocMakeXlsx(fname,sd,dr){
30261      var enc=new TextEncoder();
30262      // CRC-32 table
30263      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;}
30264      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;}
30265      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30266      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30267      // Shared string table
30268      var ss=[],si={};
30269      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30270      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30271      // Worksheet builder — each WS() call gets its own row counter R
30272      function WS(){
30273        var R=0,buf=[];
30274        function cl(c){return String.fromCharCode(65+c);}
30275        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30276          '<v>'+S(v)+'</v></c>';}
30277        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30278          (st?' s="'+st+'"':'')+'>'+
30279          '<v>'+(+v)+'</v></c>';}
30280        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30281        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30282          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30283          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30284          '<sheetFormatPr defaultRowHeight="15"/>'+
30285          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30286        return{sc:sc,nc:nc,row:row,xml:xml};
30287      }
30288      // Language breakdown
30289      var lm={};
30290      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;});
30291      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30292      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30293      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30294      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30295      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30296      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30297      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):'';}
30298      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30299      // Summary sheet
30300      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30301      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30302      r1(s1(0,proj,2));
30303      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30304      r1('');
30305      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30306      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))));
30307      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))));
30308      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))));
30309      r1('');
30310      r1(s1(0,'FILE CHANGES',8));
30311      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30312      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30313      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30314      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30315      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30316      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)));
30317      if(langs.length){
30318        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30319        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30320        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)));});
30321      }
30322      r1('');r1(s1(0,'SCAN METADATA',8));
30323      r1(s1(1,_blabel)+s1(2,_clabel));
30324      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30325      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30326      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"/>');
30327      // File Delta sheet
30328      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30329      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));
30330      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)));});
30331      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30332      // Shared strings XML
30333      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30334        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30335        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30336      // XLSX file map
30337      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30338      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>',
30339        '_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>',
30340        '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>',
30341        '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>',
30342        '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>',
30343        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30344      // ZIP packer — STORED (no compression), compatible with all XLSX readers
30345      var zparts=[],zcds=[],zoff=0,znf=0;
30346      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30347       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30348      ].forEach(function(name){
30349        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30350        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]);
30351        var entry=new Uint8Array(lha.length+nb.length+sz);
30352        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30353        zparts.push(entry);
30354        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));
30355        var cde=new Uint8Array(cda.length+nb.length);
30356        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30357        zcds.push(cde);zoff+=entry.length;znf++;
30358      });
30359      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30360      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]);
30361      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30362      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30363      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30364      zout.set(new Uint8Array(ea),zpos);
30365      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30366      var xurl=URL.createObjectURL(xblob);
30367      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30368      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30369      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30370    }
30371    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;');}
30372    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30373    function getExportFilename(ext){return _exportBase+'.'+ext;}
30374
30375    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 }}'};
30376    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;}
30377    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30378    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30379    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30380    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30381    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):'';}
30382    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30383    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)]];}
30384    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30385    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)];});}
30386    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30387    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30388
30389    // ── Chart HTML report ─────────────────────────────────────────────────────
30390    function slocChartReport(fname, sd, dr) {
30391      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30392      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30393      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30394      function fmt(n){return Number(n).toLocaleString();}
30395      function px(n){return Math.round(n);}
30396      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30397      // Language map
30398      var lm={};
30399      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;});
30400      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30401
30402      // Builds onmouse* attrs for interactive tooltip on each SVG element
30403      function barTT(label,val){
30404        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30405      }
30406
30407      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30408      //    match the Language Code Delta column height) ────────────
30409      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'}];
30410      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30411      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30412      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30413      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30414      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"/>';}
30415      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30416      c1mets.forEach(function(m,i){
30417        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30418        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30419        var gMax=Math.max(m.b,m.c)*1.15||1;
30420        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30421        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>';
30422        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))+'/>';
30423        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>';
30424        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))+'/>';
30425        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>';
30426        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>';
30427        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>';
30428      });
30429      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>';
30430      c1+='</svg>';
30431
30432      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30433      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'}];
30434      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30435      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30436      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30437      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30438      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30439      mets.forEach(function(m,i){
30440        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30441        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30442        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30443        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>';
30444        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30445        if(bw>=52){
30446          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>';
30447        }else{
30448          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30449          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>';
30450        }
30451      });
30452      c2+='</svg>';
30453
30454      // ── Chart 3: Language Code Delta ─────────────────────────────────────
30455      var c3='';
30456      if(langs.length){
30457        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30458        var C3W=550,c3LW=124,c3FW=52;
30459        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30460        var L3rH=30,C3H=langs.length*L3rH+20;
30461        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30462        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30463        langs.forEach(function(l,i){
30464          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30465          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30466          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30467          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>';
30468          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':''))+'/>';
30469          if(bw>=48){
30470            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>';
30471          }else{
30472            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30473            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>';
30474          }
30475          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>';
30476        });
30477        c3+='</svg>';
30478      }
30479
30480      // ── Chart 4: File Change Donut — centered pie with legend below
30481      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;});
30482      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30483      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30484      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">';
30485      var ang=-Math.PI/2;
30486      segs.forEach(function(s){
30487        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30488        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30489        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30490        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30491        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30492        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)+'%')+'/>';
30493        ang+=sw;
30494      });
30495      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>';
30496      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>';
30497      segs.forEach(function(s,i){
30498        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30499        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30500        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>';
30501      });
30502      c4+='</svg>';
30503
30504      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30505      var ttJs='var tt=document.getElementById("ox-tt");'+
30506        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30507        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30508        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30509        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30510        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30511        'function oxHT(){tt.style.display="none";}';
30512
30513      // body max-width keeps charts from inflating beyond design dimensions on
30514      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30515      // each chart's height blows up proportionally, breaking the one-page layout.
30516      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;}'+
30517        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30518        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30519        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30520        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30521        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30522        'svg{display:block;}'+
30523        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30524        '#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;}'+
30525        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30526      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30527        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30528        '<div id="ox-tt"><\/div>'+
30529        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
30530        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
30531        '<div class="two-col">'+
30532        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
30533        '<div class="leg">'+
30534        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30535        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30536        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30537        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
30538        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30539        '<\/div>'+
30540        '<div class="two-col">'+
30541        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30542        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30543        '<\/div>'+
30544        '<script>'+ttJs+'<\/script>'+
30545        '<\/body><\/html>';
30546      slocDownload(html, fname, 'text/html;charset=utf-8;');
30547    }
30548    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30549    window.buildDeltaChartsHtml = function() {
30550      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30551      var sd=_sd;
30552      var projEl=document.querySelector('[data-folder]');
30553      var proj=projEl?projEl.getAttribute('data-folder'):'';
30554      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30555      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30556      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30557      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30558      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";}';
30559      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);}';
30560      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30561        '<div id="ox-tt"><\/div>'+
30562        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30563        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30564        '<div class="two-col">'+
30565        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30566        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30567        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30568        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30569        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30570        '<\/div>'+
30571        '<div class="two-col">'+
30572        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30573        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30574        '<\/div>'+
30575        '<script>'+ttJs+'<\/script>'+
30576        '<\/body><\/html>';
30577    };
30578    // ── Inline delta charts ────────────────────────────────────────────────────
30579    var _icTT=document.getElementById('ic-tt');
30580    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30581    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';};
30582    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30583    window.addEventListener('blur',function(){window.icHT();});
30584    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30585    (function(){
30586      // Theme-aware palette — matches the canonical scheme used by /test-metrics
30587      // charts so every page renders bars/text/grid with the same colours and
30588      // adapts to dark mode (see Design section in CLAUDE.md).
30589      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30590      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30591      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30592      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30593      // washed) so the chart reads with the same weight as /test-metrics.
30594      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30595      var FADE=dark?'#524238':'#e6d0bf';
30596      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30597      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30598      function fmt(n){return Number(n).toLocaleString();}
30599      function px(n){return Math.round(n);}
30600      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30601      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30602      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);});}
30603      var dr=getDeltaExportRows(),sd=_sd,lm={};
30604      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;});
30605      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30606      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30607      // the bars are as tall as the (usually taller) Language Code Delta sibling that
30608      // shares the same grid row, instead of sitting short at the top.
30609      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}];
30610      function drawC1(){
30611        var C1W=600,C1H=188;
30612        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30613        if(host&&card&&host.clientWidth>0){
30614          var avW=host.clientWidth;
30615          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30616          var wantH=availPx*C1W/avW;
30617          if(wantH>C1H)C1H=wantH;
30618        }
30619        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30620        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30621        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"/>';}
30622        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30623        c1mets.forEach(function(m,i){
30624          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30625          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30626          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30627          var gMax=Math.max(m.b,m.c)*1.15||1;
30628          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30629          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>';
30630          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"/>';
30631          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>';
30632          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"/>';
30633          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>';
30634          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>';
30635          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>';
30636        });
30637        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>';
30638        c1+='</svg>';
30639        return c1;
30640      }
30641      var c1=drawC1();
30642      // Chart 2: Delta by Metric
30643      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}];
30644      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30645      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;
30646      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30647      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30648      mets.forEach(function(m,i){
30649        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);
30650        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>';
30651        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30652        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>';}
30653        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>';}
30654      });
30655      c2+='</svg>';
30656      // Chart 3: Language Code Delta
30657      var c3='';
30658      if(langs.length){
30659        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30660        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;
30661        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30662        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30663        langs.forEach(function(l,i){
30664          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);
30665          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>';
30666          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"/>';
30667          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>';}
30668          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>';}
30669          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>';
30670        });
30671        c3+='</svg>';
30672      }
30673      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30674      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30675      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;});
30676      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30677      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30678      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);
30679      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;
30680      if(segs.length===1){
30681        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30682        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+'"/>';
30683      } else {
30684        // Give every visible slice a small minimum sweep, taken from the largest
30685        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30686        // arc whose start and end points coincide, so SVG renders nothing (blank).
30687        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30688        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30689        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30690        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30691        segs.forEach(function(s,si){
30692          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30693          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);
30694          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);
30695          var pct=Math.round(s.v/tot*100);
30696          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"/>';
30697          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>';}
30698          ang+=sw;
30699        });
30700      }
30701      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30702      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30703      segs.forEach(function(s,i){
30704        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30705        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30706        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30707        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30708        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30709        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>';
30710        c4+='</g>';
30711      });
30712      c4+='</svg>';
30713      // Inject the fixed-height siblings first so the grid row settles to the (taller)
30714      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30715      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30716      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);}
30717      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30718      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30719      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30720
30721      // Compare Timeline chart (Baseline vs Current, 2 points)
30722      (function() {
30723        var activeCmpMetric='code';
30724        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30725        function renderCmpTL(metric, targetSvg, targetH) {
30726          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30727          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30728          svg.setAttribute('height',H);
30729          var pad={l:62,r:20,t:32,b:72};
30730          var dark=document.body.classList.contains('dark-theme');
30731          var cmpPts=[
30732            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30733            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30734          ];
30735          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30736          var valid=pts.filter(function(v){return v!=null;});
30737          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;}
30738          var minV=0,maxV=Math.max.apply(null,valid);
30739          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30740          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30741          var cx0=pad.l,cx1=pad.l+plotW;
30742          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30743          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30744          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30745          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30746          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30747          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();}
30748          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30749          var parts=[];
30750          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30751          for(var gi=0;gi<5;gi++){
30752            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30753            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30754            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30755          }
30756          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+'"/>');
30757          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"/>');
30758          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30759                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30760          dotPts.forEach(function(pt){
30761            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>');
30762            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"/>');
30763            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>');
30764            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>');
30765          });
30766          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30767          svg.setAttribute('viewBox','0 0 '+W+' '+H);
30768          svg.innerHTML=parts.join('');
30769          // Hover: crosshair + tooltip (matches multi-scan timeline)
30770          var cmpTT=document.getElementById('ic-tt');
30771          svg.onmousemove=function(e){
30772            var rect=svg.getBoundingClientRect();
30773            var scaleX=W/rect.width;
30774            var mouseX=(e.clientX-rect.left)*scaleX;
30775            var nearest=-1,minDist=Infinity;
30776            var cxArr=[cx0,cx1];
30777            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;}}
30778            if(nearest<0)return;
30779            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30780            var xhair=svg.querySelector('.cmp-xhair');
30781            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30782            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"/>';
30783            if(!cmpTT)return;
30784            var clbl=cmpPts[nearest].label;
30785            var scanLbl=nearest===0?'Baseline':'Current';
30786            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>';
30787            var bx=rect.left+(nc/W*rect.width)+18;
30788            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30789            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30790          };
30791          svg.onmouseleave=function(){
30792            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30793            if(cmpTT)cmpTT.style.display='none';
30794          };
30795        }
30796        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30797          btn.addEventListener('click',function(){
30798            activeCmpMetric=this.dataset.cmpMetric;
30799            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30800            this.classList.add('active');
30801            renderCmpTL(activeCmpMetric);
30802          });
30803        });
30804        var ttgl=document.getElementById('theme-toggle');
30805        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30806        if(typeof ResizeObserver!=='undefined'){
30807          var cmpSvg=document.getElementById('cmp-tl-svg');
30808          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30809        }
30810        // Expose the timeline renderer + current metric so the Full View modal can
30811        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30812        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30813        window.__sdGetMetric=function(){return activeCmpMetric;};
30814        renderCmpTL(activeCmpMetric);
30815      })();
30816
30817      // HTML legend hover -> highlight matching SVG bars within the SAME card only
30818      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30819        var metric=leg.getAttribute('data-highlight');
30820        var parentCard=leg.closest('.ic-card');
30821        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30822        if(!chartEl)return;
30823        leg.addEventListener('mouseenter',function(){
30824          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30825            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';}
30826            else{x.style.opacity='0.28';}
30827          });
30828        });
30829        leg.addEventListener('mouseleave',function(){
30830          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30831        });
30832      });
30833
30834      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30835      (function(){
30836        var ov=document.getElementById('ic-svg-modal-ov');
30837        var body=document.getElementById('ic-svg-modal-body');
30838        var ttl=document.getElementById('ic-svg-modal-title');
30839        var closeBtn=document.getElementById('ic-svg-modal-close');
30840        if(!ov||!body)return;
30841        function close(){
30842          ov.classList.remove('open');body.innerHTML='';
30843          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30844          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30845        }
30846        function open(srcId,title){
30847          var src=document.getElementById(srcId);if(!src)return;
30848          if(ttl)ttl.textContent=title||'';
30849          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30850          // snapshot stretches and loses interactivity. Re-render it live into the modal at
30851          // full size instead — keeps proportions, animation, crosshair, tooltip and the
30852          // metric tabs working exactly like the inline chart.
30853          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30854            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30855            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30856            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('');
30857            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>';
30858            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30859            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30860            ov.classList.add('open');
30861            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30862            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;}
30863            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30864              b.addEventListener('click',function(){
30865                if(!window.__sdFvTL)return;
30866                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30867                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30868                this.classList.add('active');
30869                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30870              });
30871            });
30872            return;
30873          }
30874          var card=src.closest('.ic-card');
30875          var legHtml='';
30876          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30877          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30878          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;}
30879          body.innerHTML=legHtml+inner;
30880          var svg=body.querySelector('svg');
30881          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30882          addTT(body);
30883          ov.classList.add('open');
30884        }
30885        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30886          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30887        });
30888        if(closeBtn)closeBtn.addEventListener('click',close);
30889        ov.addEventListener('click',function(e){if(e.target===ov)close();});
30890        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30891      })();
30892
30893      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30894    })();
30895  </script>
30896  {{ toast_assets|safe }}
30897  <script nonce="{{ csp_nonce }}">
30898  (function(){
30899    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'}];
30900    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);});}
30901    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30902    function init(){
30903      var btn=document.getElementById('settings-btn');if(!btn)return;
30904      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30905      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>';
30906      document.body.appendChild(m);
30907      var g=document.getElementById('scheme-grid');
30908      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);});
30909      var cl=document.getElementById('settings-close');
30910      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);});})();
30911      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');});
30912      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30913      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30914    }
30915    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30916  }());
30917  </script>
30918  <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]';
30919  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;}
30920  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>
30921</body>
30922</html>
30923"##,
30924    ext = "html"
30925)]
30926// Template structs need many bool fields to pass Askama rendering flags.
30927#[allow(clippy::struct_excessive_bools)]
30928struct CompareTemplate {
30929    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30930    loading_overlay: String,
30931    version: &'static str,
30932    project_label: String,
30933    baseline_git_commit: String,
30934    current_git_commit: String,
30935    baseline_run_id: String,
30936    current_run_id: String,
30937    baseline_run_id_short: String,
30938    current_run_id_short: String,
30939    baseline_timestamp: String,
30940    baseline_timestamp_utc_ms: i64,
30941    current_timestamp: String,
30942    current_timestamp_utc_ms: i64,
30943    project_path: String,
30944    baseline_code: u64,
30945    current_code: u64,
30946    code_lines_delta_str: String,
30947    code_lines_delta_class: String,
30948    baseline_files: u64,
30949    current_files: u64,
30950    files_analyzed_delta_str: String,
30951    files_analyzed_delta_class: String,
30952    baseline_comments: u64,
30953    current_comments: u64,
30954    comment_lines_delta_str: String,
30955    comment_lines_delta_class: String,
30956    baseline_code_fmt: String,
30957    current_code_fmt: String,
30958    baseline_files_fmt: String,
30959    current_files_fmt: String,
30960    baseline_comments_fmt: String,
30961    current_comments_fmt: String,
30962    code_lines_pct_str: String,
30963    files_analyzed_pct_str: String,
30964    comment_lines_pct_str: String,
30965    code_lines_added: i64,
30966    code_lines_removed: i64,
30967    /// Code lines residing in files modified between the two scans (current-scan counts).
30968    code_lines_modified: i64,
30969    /// Code lines residing in files identical between the two scans.
30970    code_lines_unmodified: i64,
30971    /// Sum of added + removed + modified + unmodified code-line metrics.
30972    code_lines_total: i64,
30973    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30974    new_scope: bool,
30975    churn_rate_str: String,
30976    churn_rate_class: String,
30977    scope_flag: bool,
30978    files_added: usize,
30979    files_removed: usize,
30980    files_modified: usize,
30981    files_unchanged: usize,
30982    files_total: usize,
30983    file_rows: Vec<CompareFileDeltaRow>,
30984    baseline_git_author: Option<String>,
30985    current_git_author: Option<String>,
30986    baseline_git_branch: String,
30987    current_git_branch: String,
30988    baseline_git_tags: Option<String>,
30989    current_git_tags: Option<String>,
30990    baseline_git_commit_date: Option<String>,
30991    current_git_commit_date: Option<String>,
30992    project_name: String,
30993    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30994    submodule_options: Vec<String>,
30995    /// True when either run has submodule data — controls whether the scope bar is shown.
30996    has_any_submodule_data: bool,
30997    /// The submodule currently being compared, if the `sub` query param was provided.
30998    active_submodule: Option<String>,
30999    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
31000    super_scope_active: bool,
31001    csp_nonce: String,
31002    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
31003    toast_assets: String,
31004    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
31005    coverage_delta_card: String,
31006    baseline_test_count: u64,
31007    current_test_count: u64,
31008    baseline_coverage_pct: Option<f64>,
31009    current_coverage_pct: Option<f64>,
31010}
31011
31012// ── LoginTemplate ──────────────────────────────────────────────────────────────
31013
31014#[derive(Template)]
31015#[template(
31016    source = r##"
31017<!doctype html>
31018<html lang="en">
31019<head>
31020  <meta charset="utf-8">
31021  <meta name="viewport" content="width=device-width, initial-scale=1">
31022  <title>OxideSLOC | Sign In</title>
31023  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31024  <style nonce="{{ csp_nonce }}">
31025    :root {
31026      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31027      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31028      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31029      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31030    }
31031    *{box-sizing:border-box;}
31032    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);}
31033    .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);}
31034    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31035    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31036    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31037    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31038    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31039    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31040    .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;}
31041    @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));}}
31042    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31043    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31044    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31045    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31046    .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;}
31047    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31048    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;}
31049    input[type=password]:focus{border-color:var(--oxide);}
31050    .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;}
31051    .btn:hover{opacity:.88;}
31052    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31053    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31054  </style>
31055</head>
31056<body>
31057  <div class="background-watermarks" aria-hidden="true">
31058    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31059    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31060    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31061    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31062    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31063    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31064    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31065  </div>
31066  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31067<nav class="top-nav">
31068  <a class="brand" href="/">
31069    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31070    <span class="brand-title">OxideSLOC</span>
31071  </a>
31072</nav>
31073<main class="page">
31074  <div class="card">
31075    <h1>Sign In</h1>
31076    <p class="subtitle">Enter the API key printed when the server started.</p>
31077    {% if has_error %}
31078    <div class="error">Incorrect API key — please try again.</div>
31079    {% endif %}
31080    <form method="POST" action="/auth/login">
31081      <input type="hidden" name="next" value="{{ next_url|e }}">
31082      <label for="key">API Key</label>
31083      <input id="key" type="password" name="key" autocomplete="current-password"
31084             placeholder="Paste your API key here" autofocus>
31085      <button type="submit" class="btn">Sign In</button>
31086    </form>
31087    <p class="hint">
31088      The API key was printed in the terminal when the server started.<br>
31089      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31090      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31091    </p>
31092  </div>
31093</main>
31094<script nonce="{{ csp_nonce }}">
31095(function() {
31096  (function randomizeWatermarks() {
31097    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31098    if (!wms.length) return;
31099    var placed = [];
31100    function tooClose(top, left) {
31101      for (var i = 0; i < placed.length; i++) {
31102        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31103        if (dt < 16 && dl < 12) return true;
31104      }
31105      return false;
31106    }
31107    function pick(leftBand) {
31108      for (var attempt = 0; attempt < 50; attempt++) {
31109        var top = Math.random() * 88 + 2;
31110        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31111        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31112      }
31113      var top = Math.random() * 88 + 2;
31114      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31115      placed.push([top, left]); return [top, left];
31116    }
31117    var half = Math.floor(wms.length / 2);
31118    wms.forEach(function (img, i) {
31119      var pos = pick(i < half);
31120      var size = Math.floor(Math.random() * 100 + 120);
31121      var rot = (Math.random() * 360).toFixed(1);
31122      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31123      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;
31124    });
31125  })();
31126  (function spawnCodeParticles() {
31127    var container = document.getElementById('code-particles');
31128    if (!container) return;
31129    var snippets = [
31130      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31131      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31132      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31133      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31134      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31135    ];
31136    var count = 38;
31137    for (var i = 0; i < count; i++) {
31138      (function(idx) {
31139        var el = document.createElement('span');
31140        el.className = 'code-particle';
31141        el.textContent = snippets[idx % snippets.length];
31142        var left = Math.random() * 94 + 2;
31143        var top = Math.random() * 88 + 6;
31144        var dur = (Math.random() * 10 + 9).toFixed(1);
31145        var delay = (Math.random() * 18).toFixed(1);
31146        var rot = (Math.random() * 26 - 13).toFixed(1);
31147        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31148        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31149        container.appendChild(el);
31150      })(i);
31151    }
31152  })();
31153})();
31154</script>
31155</body>
31156</html>
31157"##,
31158    ext = "html"
31159)]
31160pub(crate) struct LoginTemplate {
31161    pub(crate) csp_nonce: String,
31162    pub(crate) has_error: bool,
31163    pub(crate) next_url: String,
31164    pub(crate) lockout_threshold: u32,
31165}
31166
31167// ── REST API reference page ────────────────────────────────────────────────────
31168
31169#[derive(Template)]
31170#[template(
31171    source = r##"
31172<!doctype html>
31173<html lang="en">
31174<head>
31175  <meta charset="utf-8">
31176  <meta name="viewport" content="width=device-width, initial-scale=1">
31177  <title>OxideSLOC — REST API Reference</title>
31178  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31179  <style nonce="{{ csp_nonce }}">
31180    :root {
31181      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31182      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31183      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31184      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31185      --success:#16a34a;
31186    }
31187    body.dark-theme {
31188      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31189      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31190    }
31191    *{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;}
31192    .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);}
31193    .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;}
31194    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31195    .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));}
31196    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31197    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31198    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31199    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31200    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31201    @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; } }
31202    .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;}
31203    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31204    .nav-pill.active{background:rgba(255,255,255,0.22);}
31205    .nav-dropdown{position:relative;display:inline-flex;}
31206    .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;}
31207    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31208    .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;}
31209    .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;}
31210    .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);}
31211    .nav-dropdown-menu a:last-child{border-bottom:none;}
31212    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31213    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31214    .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;}
31215    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31216    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31217    .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;}
31218    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31219    .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);}
31220    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31221    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31222    .settings-modal-body{padding:14px 16px 16px;}
31223    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31224    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31225    .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;}
31226    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31227    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31228    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31229    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31230    .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;}
31231    .tz-select:focus{border-color:var(--oxide);}
31232    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31233    .page-header{margin-bottom:28px;}
31234    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31235    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31236    .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;}
31237    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31238    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31239    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31240    .callout strong{font-weight:800;}
31241    .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;}
31242    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31243    .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;}
31244    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31245    .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;}
31246    body.dark-theme .base-url-value{color:var(--accent);}
31247    .section{margin-bottom:36px;}
31248    .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);}
31249    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31250    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31251    .ep-header:hover{background:var(--surface-2);}
31252    .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;}
31253    .method.get{background:#dcfce7;color:#166534;}
31254    .method.post{background:#dbeafe;color:#1e40af;}
31255    .method.delete{background:#fee2e2;color:#991b1b;}
31256    body.dark-theme .method.get{background:#14532d;color:#86efac;}
31257    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31258    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31259    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31260    .ep-path .param{color:var(--oxide-2);}
31261    body.dark-theme .ep-path .param{color:var(--oxide);}
31262    .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;}
31263    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31264    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31265    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31266    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31267    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31268    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31269    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31270    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31271    .ep-card.open .chevron{transform:rotate(180deg);}
31272    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31273    .ep-card.open .ep-body{display:block;}
31274    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31275    .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;}
31276    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31277    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31278    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31279    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31280    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);}
31281    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31282    table.params tr:last-child td{border-bottom:none;}
31283    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31284    .pt-type{color:var(--muted-2);font-size:12px;}
31285    .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;}
31286    .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;}
31287    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31288    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31289    details.schema{margin-bottom:14px;}
31290    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;}
31291    details.schema summary:hover{color:var(--text);}
31292    .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;}
31293    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31294    .curl-wrap{position:relative;}
31295    .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;}
31296    .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;}
31297    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31298    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31299    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31300    .webhook-note a{color:var(--accent-2);text-decoration:none;}
31301    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31302    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31303    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31304    .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;}
31305    @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));}}
31306    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31307    .site-footer a{color:var(--muted);}
31308  </style>
31309</head>
31310<body>
31311  <div class="background-watermarks" aria-hidden="true">
31312    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31313    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31314    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31315    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31316    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31317    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31318    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31319  </div>
31320  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31321  <div class="top-nav">
31322    <div class="top-nav-inner">
31323      <a class="brand" href="/">
31324        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31325        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31326      </a>
31327      <div class="nav-right">
31328        <a class="nav-pill" href="/">Home</a>
31329        <div class="nav-dropdown">
31330          <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>
31331          <div class="nav-dropdown-menu">
31332            <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>
31333          </div>
31334        </div>
31335        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31336        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31337        <div class="nav-dropdown">
31338          <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>
31339          <div class="nav-dropdown-menu">
31340            <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>
31341          </div>
31342        </div>
31343        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31344          <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>
31345        </button>
31346        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31347          <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>
31348          <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>
31349        </button>
31350      </div>
31351    </div>
31352  </div>
31353
31354  <div class="page">
31355    <div class="page-header">
31356      <h1 class="page-title">REST API Reference</h1>
31357      <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>
31358    </div>
31359
31360    {% if has_api_key %}
31361    <div class="callout key-set">
31362      <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>
31363      <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>
31364    </div>
31365    {% else %}
31366    <div class="callout no-key">
31367      <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>
31368      <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>
31369    </div>
31370    {% endif %}
31371
31372    <div class="base-url-bar">
31373      <span class="base-url-label">Base URL</span>
31374      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31375    </div>
31376
31377    <!-- Health -->
31378    <div class="section">
31379      <h2 class="section-title">Health &amp; Status</h2>
31380      <div class="ep-card">
31381        <div class="ep-header">
31382          <span class="method get">GET</span>
31383          <span class="ep-path">/healthz</span>
31384          <span class="auth-badge public">Public</span>
31385          <span class="ep-desc">Server liveness check</span>
31386          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31387        </div>
31388        <div class="ep-body">
31389          <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>
31390          <p class="params-heading">Response</p>
31391          <div class="schema-block">200 OK
31392Content-Type: text/plain
31393
31394ok</div>
31395          <p class="curl-heading">Example</p>
31396          <div class="curl-wrap">
31397            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31398            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31399          </div>
31400        </div>
31401      </div>
31402    </div>
31403
31404    <!-- Badges -->
31405    <div class="section">
31406      <h2 class="section-title">Badges</h2>
31407      <div class="ep-card">
31408        <div class="ep-header">
31409          <span class="method get">GET</span>
31410          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31411          <span class="auth-badge public">Public</span>
31412          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31413          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31414        </div>
31415        <div class="ep-body">
31416          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31417          <p class="params-heading">Path Parameters</p>
31418          <table class="params">
31419            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31420            <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>
31421          </table>
31422          <p class="curl-heading">Example</p>
31423          <div class="curl-wrap">
31424            <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>
31425            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31426          </div>
31427        </div>
31428      </div>
31429    </div>
31430
31431    <!-- Metrics -->
31432    <div class="section">
31433      <h2 class="section-title">Metrics</h2>
31434
31435      <div class="ep-card">
31436        <div class="ep-header">
31437          <span class="method get">GET</span>
31438          <span class="ep-path">/api/metrics/latest</span>
31439          <span class="auth-badge protected">Protected</span>
31440          <span class="ep-desc">Latest scan metrics (JSON)</span>
31441          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31442        </div>
31443        <div class="ep-body">
31444          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31445          <details class="schema"><summary>Response schema</summary>
31446<div class="schema-block">{
31447  "run_id":    string,        // UUID
31448  "timestamp": string,        // ISO-8601 UTC
31449  "project":   string,        // scanned root path
31450  "summary": {
31451    "files_analyzed":       number,
31452    "files_skipped":        number,
31453    "code_lines":           number,
31454    "comment_lines":        number,
31455    "blank_lines":          number,
31456    "total_physical_lines": number,
31457    "functions":            number,
31458    "classes":              number,
31459    "variables":            number,
31460    "imports":              number
31461  },
31462  "languages": [
31463    { "name": string, "files": number, "code_lines": number,
31464      "comment_lines": number, "blank_lines": number,
31465      "functions": number, "classes": number,
31466      "variables": number, "imports": number }
31467  ]
31468}</div></details>
31469          <p class="curl-heading">Example</p>
31470          <div class="curl-wrap">
31471            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31472  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31473            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31474          </div>
31475        </div>
31476      </div>
31477
31478      <div class="ep-card">
31479        <div class="ep-header">
31480          <span class="method get">GET</span>
31481          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31482          <span class="auth-badge protected">Protected</span>
31483          <span class="ep-desc">Metrics for a specific run</span>
31484          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31485        </div>
31486        <div class="ep-body">
31487          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31488          <p class="params-heading">Path Parameters</p>
31489          <table class="params">
31490            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31491            <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>
31492          </table>
31493          <p class="curl-heading">Example</p>
31494          <div class="curl-wrap">
31495            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31496  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
31497            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31498          </div>
31499        </div>
31500      </div>
31501
31502      <div class="ep-card">
31503        <div class="ep-header">
31504          <span class="method get">GET</span>
31505          <span class="ep-path">/api/metrics/history</span>
31506          <span class="auth-badge protected">Protected</span>
31507          <span class="ep-desc">Paginated scan history</span>
31508          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31509        </div>
31510        <div class="ep-body">
31511          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31512          <p class="params-heading">Query Parameters</p>
31513          <table class="params">
31514            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31515            <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>
31516            <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>
31517          </table>
31518          <details class="schema"><summary>Response schema</summary>
31519<div class="schema-block">[{
31520  "run_id":         string,
31521  "timestamp":      string,   // ISO-8601 UTC
31522  "commit":         string | null,
31523  "branch":         string | null,
31524  "tags":           string[],
31525  "code_lines":     number,
31526  "comment_lines":  number,
31527  "blank_lines":    number,
31528  "physical_lines": number,
31529  "files_analyzed": number,
31530  "project_label":  string,
31531  "html_url":       string | null
31532}]</div></details>
31533          <p class="curl-heading">Example</p>
31534          <div class="curl-wrap">
31535            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31536  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31537            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31538          </div>
31539        </div>
31540      </div>
31541
31542      <div class="ep-card">
31543        <div class="ep-header">
31544          <span class="method get">GET</span>
31545          <span class="ep-path">/api/project-history</span>
31546          <span class="auth-badge protected">Protected</span>
31547          <span class="ep-desc">Project-level scan summary</span>
31548          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31549        </div>
31550        <div class="ep-body">
31551          <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>
31552          <p class="params-heading">Query Parameters</p>
31553          <table class="params">
31554            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31555            <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>
31556          </table>
31557          <details class="schema"><summary>Response schema</summary>
31558<div class="schema-block">{
31559  "scan_count":           number,
31560  "last_scan_id":         string | null,
31561  "last_scan_timestamp":  string | null,  // ISO-8601
31562  "last_scan_code_lines": number | null,
31563  "last_git_branch":      string | null,
31564  "last_git_commit":      string | null
31565}</div></details>
31566          <p class="curl-heading">Example</p>
31567          <div class="curl-wrap">
31568            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31569  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31570            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31571          </div>
31572        </div>
31573      </div>
31574
31575      <div class="ep-card">
31576        <div class="ep-header">
31577          <span class="method get">GET</span>
31578          <span class="ep-path">/api/metrics/submodules</span>
31579          <span class="auth-badge protected">Protected</span>
31580          <span class="ep-desc">List known git submodules across scans</span>
31581          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31582        </div>
31583        <div class="ep-body">
31584          <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>
31585          <p class="params-heading">Query Parameters</p>
31586          <table class="params">
31587            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31588            <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>
31589          </table>
31590          <details class="schema"><summary>Response schema</summary>
31591<div class="schema-block">[{
31592  "name":          string,  // submodule name
31593  "relative_path": string   // path relative to the project root
31594}]</div></details>
31595          <p class="curl-heading">Example</p>
31596          <div class="curl-wrap">
31597            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31598  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31599            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31600          </div>
31601        </div>
31602      </div>
31603    </div>
31604
31605    <!-- Async Run Status -->
31606    <div class="section">
31607      <h2 class="section-title">Async Run Status</h2>
31608
31609      <div class="ep-card">
31610        <div class="ep-header">
31611          <span class="method get">GET</span>
31612          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31613          <span class="auth-badge protected">Protected</span>
31614          <span class="ep-desc">Poll scan completion</span>
31615          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31616        </div>
31617        <div class="ep-body">
31618          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31619          <details class="schema"><summary>Response schema</summary>
31620<div class="schema-block">// Running
31621{ "state": "running",  "elapsed_secs": number }
31622
31623// Complete
31624{ "state": "complete", "run_id": string }
31625
31626// Failed
31627{ "state": "failed",   "message": string }</div></details>
31628          <p class="curl-heading">Example</p>
31629          <div class="curl-wrap">
31630            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31631  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
31632            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31633          </div>
31634        </div>
31635      </div>
31636
31637      <div class="ep-card">
31638        <div class="ep-header">
31639          <span class="method get">GET</span>
31640          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31641          <span class="auth-badge protected">Protected</span>
31642          <span class="ep-desc">Poll PDF generation readiness</span>
31643          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31644        </div>
31645        <div class="ep-body">
31646          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31647          <details class="schema"><summary>Response schema</summary>
31648<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31649          <p class="curl-heading">Example</p>
31650          <div class="curl-wrap">
31651            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31652  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
31653            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31654          </div>
31655        </div>
31656      </div>
31657
31658      <div class="ep-card">
31659        <div class="ep-header">
31660          <span class="method post">POST</span>
31661          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31662          <span class="auth-badge protected">Protected</span>
31663          <span class="ep-desc">Cancel a running scan</span>
31664          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31665        </div>
31666        <div class="ep-body">
31667          <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>
31668          <p class="curl-heading">Example</p>
31669          <div class="curl-wrap">
31670            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31671  -H "Authorization: Bearer $SLOC_API_KEY" \
31672  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
31673            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31674          </div>
31675        </div>
31676      </div>
31677    </div>
31678
31679    <!-- Run Management -->
31680    <div class="section">
31681      <h2 class="section-title">Run Management</h2>
31682
31683      <div class="ep-card">
31684        <div class="ep-header">
31685          <span class="method get">GET</span>
31686          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31687          <span class="auth-badge protected">Protected</span>
31688          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31689          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31690        </div>
31691        <div class="ep-body">
31692          <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>
31693          <p class="params-heading">Path Parameters</p>
31694          <table class="params">
31695            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31696            <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>
31697          </table>
31698          <details class="schema"><summary>Response</summary>
31699<div class="schema-block">200 OK — Content-Type: application/zip
31700Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
31701
31702404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
31703          <p class="curl-heading">Example</p>
31704          <div class="curl-wrap">
31705            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31706  -o run.zip \
31707  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
31708            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31709          </div>
31710        </div>
31711      </div>
31712
31713      <div class="ep-card">
31714        <div class="ep-header">
31715          <span class="method delete">DELETE</span>
31716          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31717          <span class="auth-badge protected">Protected</span>
31718          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31719          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31720        </div>
31721        <div class="ep-body">
31722          <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>
31723          <p class="params-heading">Path Parameters</p>
31724          <table class="params">
31725            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31726            <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>
31727          </table>
31728          <details class="schema"><summary>Response</summary>
31729<div class="schema-block">204 No Content — run successfully deleted
31730
31731500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
31732          <p class="curl-heading">Example</p>
31733          <div class="curl-wrap">
31734            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31735  -H "Authorization: Bearer $SLOC_API_KEY" \
31736  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
31737            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31738          </div>
31739        </div>
31740      </div>
31741
31742      <div class="ep-card">
31743        <div class="ep-header">
31744          <span class="method post">POST</span>
31745          <span class="ep-path">/api/runs/cleanup</span>
31746          <span class="auth-badge protected">Protected</span>
31747          <span class="ep-desc">Bulk delete runs older than N days</span>
31748          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31749        </div>
31750        <div class="ep-body">
31751          <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>
31752          <p class="params-heading">Request Body (application/json)</p>
31753          <table class="params">
31754            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31755            <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>
31756          </table>
31757          <details class="schema"><summary>Response schema</summary>
31758<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
31759          <p class="curl-heading">Example — delete runs older than 60 days</p>
31760          <div class="curl-wrap">
31761            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31762  -H "Authorization: Bearer $SLOC_API_KEY" \
31763  -H "Content-Type: application/json" \
31764  -d '{"older_than_days":60}' \
31765  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31766            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31767          </div>
31768        </div>
31769      </div>
31770    </div>
31771
31772    <!-- Retention Policy -->
31773    <div class="section">
31774      <h2 class="section-title">Retention Policy</h2>
31775
31776      <div class="ep-card">
31777        <div class="ep-header">
31778          <span class="method get">GET</span>
31779          <span class="ep-path">/api/cleanup-policy</span>
31780          <span class="auth-badge protected">Protected</span>
31781          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31782          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31783        </div>
31784        <div class="ep-body">
31785          <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>
31786          <details class="schema"><summary>Response schema</summary>
31787<div class="schema-block">{
31788  "policy": {
31789    "enabled":       boolean,
31790    "max_age_days":  number | null,   // delete runs older than N days
31791    "max_run_count": number | null,   // keep only the N most recent runs
31792    "interval_hours": number          // hours between background passes
31793  } | null,
31794  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
31795  "last_run_deleted": number | null   // runs deleted in last pass
31796}</div></details>
31797          <p class="curl-heading">Example</p>
31798          <div class="curl-wrap">
31799            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31800  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31801            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31802          </div>
31803        </div>
31804      </div>
31805
31806      <div class="ep-card">
31807        <div class="ep-header">
31808          <span class="method post">POST</span>
31809          <span class="ep-path">/api/cleanup-policy</span>
31810          <span class="auth-badge protected">Protected</span>
31811          <span class="ep-desc">Save or update the retention policy</span>
31812          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31813        </div>
31814        <div class="ep-body">
31815          <p class="ep-desc-full">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>
31816          <p class="params-heading">Request Body (application/json)</p>
31817          <table class="params">
31818            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31819            <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>
31820            <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>
31821            <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>
31822            <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>
31823          </table>
31824          <details class="schema"><summary>Response</summary>
31825<div class="schema-block">204 No Content — policy saved and task (re)started
31826
31827500 Internal Server Error — { "error": string }</div></details>
31828          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31829          <div class="curl-wrap">
31830            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31831  -H "Authorization: Bearer $SLOC_API_KEY" \
31832  -H "Content-Type: application/json" \
31833  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31834  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31835            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31836          </div>
31837        </div>
31838      </div>
31839
31840      <div class="ep-card">
31841        <div class="ep-header">
31842          <span class="method post">POST</span>
31843          <span class="ep-path">/api/cleanup-policy/run-now</span>
31844          <span class="auth-badge protected">Protected</span>
31845          <span class="ep-desc">Trigger an immediate cleanup pass</span>
31846          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31847        </div>
31848        <div class="ep-body">
31849          <p class="ep-desc-full">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>
31850          <details class="schema"><summary>Response schema</summary>
31851<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
31852          <p class="curl-heading">Example</p>
31853          <div class="curl-wrap">
31854            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31855  -H "Authorization: Bearer $SLOC_API_KEY" \
31856  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31857            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31858          </div>
31859        </div>
31860      </div>
31861
31862      <div class="ep-card">
31863        <div class="ep-header">
31864          <span class="method delete">DELETE</span>
31865          <span class="ep-path">/api/cleanup-policy</span>
31866          <span class="auth-badge protected">Protected</span>
31867          <span class="ep-desc">Remove the retention policy and stop the background task</span>
31868          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31869        </div>
31870        <div class="ep-body">
31871          <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>
31872          <details class="schema"><summary>Response</summary>
31873<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31874          <p class="curl-heading">Example</p>
31875          <div class="curl-wrap">
31876            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31877  -H "Authorization: Bearer $SLOC_API_KEY" \
31878  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31879            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31880          </div>
31881        </div>
31882      </div>
31883    </div>
31884
31885    <!-- Scan Profiles -->
31886    <div class="section">
31887      <h2 class="section-title">Scan Profiles</h2>
31888
31889      <div class="ep-card">
31890        <div class="ep-header">
31891          <span class="method get">GET</span>
31892          <span class="ep-path">/api/scan-profiles</span>
31893          <span class="auth-badge protected">Protected</span>
31894          <span class="ep-desc">List saved scan profiles</span>
31895          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31896        </div>
31897        <div class="ep-body">
31898          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31899          <details class="schema"><summary>Response schema</summary>
31900<div class="schema-block">{
31901  "profiles": [{
31902    "id":         string,   // UUID
31903    "name":       string,
31904    "created_at": string,   // ISO-8601
31905    "params":     object
31906  }]
31907}</div></details>
31908          <p class="curl-heading">Example</p>
31909          <div class="curl-wrap">
31910            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31911  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31912            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31913          </div>
31914        </div>
31915      </div>
31916
31917      <div class="ep-card">
31918        <div class="ep-header">
31919          <span class="method post">POST</span>
31920          <span class="ep-path">/api/scan-profiles</span>
31921          <span class="auth-badge protected">Protected</span>
31922          <span class="ep-desc">Save a scan profile</span>
31923          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31924        </div>
31925        <div class="ep-body">
31926          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31927          <p class="params-heading">Request Body (application/json)</p>
31928          <table class="params">
31929            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31930            <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>
31931            <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>
31932          </table>
31933          <details class="schema"><summary>Response schema</summary>
31934<div class="schema-block">{ "ok": true }</div></details>
31935          <p class="curl-heading">Example</p>
31936          <div class="curl-wrap">
31937            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31938  -H "Authorization: Bearer $SLOC_API_KEY" \
31939  -H "Content-Type: application/json" \
31940  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31941  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31942            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31943          </div>
31944        </div>
31945      </div>
31946
31947      <div class="ep-card">
31948        <div class="ep-header">
31949          <span class="method delete">DELETE</span>
31950          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31951          <span class="auth-badge protected">Protected</span>
31952          <span class="ep-desc">Delete a scan profile</span>
31953          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31954        </div>
31955        <div class="ep-body">
31956          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31957          <p class="params-heading">Path Parameters</p>
31958          <table class="params">
31959            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31960            <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>
31961          </table>
31962          <details class="schema"><summary>Response schema</summary>
31963<div class="schema-block">{ "ok": true }</div></details>
31964          <p class="curl-heading">Example</p>
31965          <div class="curl-wrap">
31966            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31967  -H "Authorization: Bearer $SLOC_API_KEY" \
31968  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31969            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31970          </div>
31971        </div>
31972      </div>
31973    </div>
31974
31975    <!-- Scheduled Scans -->
31976    <div class="section">
31977      <h2 class="section-title">Scheduled Scans</h2>
31978
31979      <div class="ep-card">
31980        <div class="ep-header">
31981          <span class="method get">GET</span>
31982          <span class="ep-path">/api/schedules</span>
31983          <span class="auth-badge protected">Protected</span>
31984          <span class="ep-desc">List configured schedules</span>
31985          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31986        </div>
31987        <div class="ep-body">
31988          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31989          <p class="curl-heading">Example</p>
31990          <div class="curl-wrap">
31991            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31992  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31993            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31994          </div>
31995        </div>
31996      </div>
31997
31998      <div class="ep-card">
31999        <div class="ep-header">
32000          <span class="method post">POST</span>
32001          <span class="ep-path">/api/schedules</span>
32002          <span class="auth-badge protected">Protected</span>
32003          <span class="ep-desc">Create a schedule</span>
32004          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32005        </div>
32006        <div class="ep-body">
32007          <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>
32008          <p class="curl-heading">Example</p>
32009          <div class="curl-wrap">
32010            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32011  -H "Authorization: Bearer $SLOC_API_KEY" \
32012  -H "Content-Type: application/json" \
32013  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32014  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32015            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32016          </div>
32017        </div>
32018      </div>
32019
32020      <div class="ep-card">
32021        <div class="ep-header">
32022          <span class="method delete">DELETE</span>
32023          <span class="ep-path">/api/schedules</span>
32024          <span class="auth-badge protected">Protected</span>
32025          <span class="ep-desc">Delete a schedule</span>
32026          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32027        </div>
32028        <div class="ep-body">
32029          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32030          <p class="curl-heading">Example</p>
32031          <div class="curl-wrap">
32032            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32033  -H "Authorization: Bearer $SLOC_API_KEY" \
32034  -H "Content-Type: application/json" \
32035  -d '{"id":"&lt;schedule_id&gt;"}' \
32036  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32037            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32038          </div>
32039        </div>
32040      </div>
32041    </div>
32042
32043    <!-- Git Browser -->
32044    <div class="section">
32045      <h2 class="section-title">Git Browser</h2>
32046
32047      <div class="ep-card">
32048        <div class="ep-header">
32049          <span class="method get">GET</span>
32050          <span class="ep-path">/api/git/refs</span>
32051          <span class="auth-badge protected">Protected</span>
32052          <span class="ep-desc">List git refs for a repository</span>
32053          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32054        </div>
32055        <div class="ep-body">
32056          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32057          <p class="params-heading">Query Parameters</p>
32058          <table class="params">
32059            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32060            <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>
32061          </table>
32062          <p class="curl-heading">Example</p>
32063          <div class="curl-wrap">
32064            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32065  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
32066            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32067          </div>
32068        </div>
32069      </div>
32070
32071      <div class="ep-card">
32072        <div class="ep-header">
32073          <span class="method get">GET</span>
32074          <span class="ep-path">/api/git/scan-ref</span>
32075          <span class="auth-badge protected">Protected</span>
32076          <span class="ep-desc">SLOC-scan a specific git ref</span>
32077          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32078        </div>
32079        <div class="ep-body">
32080          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32081          <p class="params-heading">Query Parameters</p>
32082          <table class="params">
32083            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32084            <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>
32085            <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>
32086          </table>
32087          <p class="curl-heading">Example</p>
32088          <div class="curl-wrap">
32089            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32090  "<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>
32091            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32092          </div>
32093        </div>
32094      </div>
32095
32096      <div class="ep-card">
32097        <div class="ep-header">
32098          <span class="method get">GET</span>
32099          <span class="ep-path">/api/git/compare-refs</span>
32100          <span class="auth-badge protected">Protected</span>
32101          <span class="ep-desc">Compare SLOC across two git refs</span>
32102          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32103        </div>
32104        <div class="ep-body">
32105          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32106          <p class="params-heading">Query Parameters</p>
32107          <table class="params">
32108            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32109            <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>
32110            <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>
32111            <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>
32112          </table>
32113          <p class="curl-heading">Example</p>
32114          <div class="curl-wrap">
32115            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32116  "<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>
32117            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32118          </div>
32119        </div>
32120      </div>
32121    </div>
32122
32123    <!-- Webhooks -->
32124    <div class="section">
32125      <h2 class="section-title">Webhooks</h2>
32126      <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>
32127
32128      <div class="ep-card">
32129        <div class="ep-header">
32130          <span class="method post">POST</span>
32131          <span class="ep-path">/webhooks/github</span>
32132          <span class="auth-badge hmac">HMAC</span>
32133          <span class="ep-desc">GitHub push event receiver</span>
32134          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32135        </div>
32136        <div class="ep-body">
32137          <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>
32138          <p class="params-heading">Required Headers</p>
32139          <table class="params">
32140            <tr><th>Header</th><th>Value</th></tr>
32141            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32142            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32143            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32144          </table>
32145        </div>
32146      </div>
32147
32148      <div class="ep-card">
32149        <div class="ep-header">
32150          <span class="method post">POST</span>
32151          <span class="ep-path">/webhooks/gitlab</span>
32152          <span class="auth-badge hmac">HMAC</span>
32153          <span class="ep-desc">GitLab push event receiver</span>
32154          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32155        </div>
32156        <div class="ep-body">
32157          <p class="ep-desc-full">Receives GitLab <code>Push Hook</code> events. Authenticated via <code>X-Gitlab-Token</code> matching the per-schedule secret.</p>
32158          <p class="params-heading">Required Headers</p>
32159          <table class="params">
32160            <tr><th>Header</th><th>Value</th></tr>
32161            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32162            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32163            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32164          </table>
32165        </div>
32166      </div>
32167
32168      <div class="ep-card">
32169        <div class="ep-header">
32170          <span class="method post">POST</span>
32171          <span class="ep-path">/webhooks/bitbucket</span>
32172          <span class="auth-badge hmac">HMAC</span>
32173          <span class="ep-desc">Bitbucket push event receiver</span>
32174          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32175        </div>
32176        <div class="ep-body">
32177          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32178          <p class="params-heading">Required Headers</p>
32179          <table class="params">
32180            <tr><th>Header</th><th>Value</th></tr>
32181            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32182            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32183          </table>
32184        </div>
32185      </div>
32186    </div>
32187
32188    <!-- Config -->
32189    <div class="section">
32190      <h2 class="section-title">Config Import / Export</h2>
32191
32192      <div class="ep-card">
32193        <div class="ep-header">
32194          <span class="method get">GET</span>
32195          <span class="ep-path">/export-config</span>
32196          <span class="auth-badge protected">Protected</span>
32197          <span class="ep-desc">Export server configuration as JSON</span>
32198          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32199        </div>
32200        <div class="ep-body">
32201          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32202          <p class="curl-heading">Example</p>
32203          <div class="curl-wrap">
32204            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32205  -o config.json \
32206  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32207            <button class="curl-copy-btn" data-target="c-export">Copy</button>
32208          </div>
32209        </div>
32210      </div>
32211
32212      <div class="ep-card">
32213        <div class="ep-header">
32214          <span class="method post">POST</span>
32215          <span class="ep-path">/import-config</span>
32216          <span class="auth-badge protected">Protected</span>
32217          <span class="ep-desc">Import server configuration</span>
32218          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32219        </div>
32220        <div class="ep-body">
32221          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32222          <p class="curl-heading">Example</p>
32223          <div class="curl-wrap">
32224            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32225  -H "Authorization: Bearer $SLOC_API_KEY" \
32226  -H "Content-Type: application/json" \
32227  -d @config.json \
32228  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32229            <button class="curl-copy-btn" data-target="c-import">Copy</button>
32230          </div>
32231        </div>
32232      </div>
32233    </div>
32234
32235    <!-- CI Ingest -->
32236    <div class="section">
32237      <h2 class="section-title">CI Ingest</h2>
32238
32239      <div class="ep-card">
32240        <div class="ep-header">
32241          <span class="method post">POST</span>
32242          <span class="ep-path">/api/ingest</span>
32243          <span class="auth-badge protected">Protected</span>
32244          <span class="ep-desc">Push a pre-computed scan result from CI</span>
32245          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32246        </div>
32247        <div class="ep-body">
32248          <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>
32249          <p class="params-heading">Query Parameters</p>
32250          <table class="params">
32251            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32252            <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>
32253          </table>
32254          <p class="params-heading">Request Body (application/json)</p>
32255          <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>
32256          <details class="schema"><summary>Response schema</summary>
32257<div class="schema-block">// 201 Created
32258{
32259  "run_id":   string,  // UUID of the ingested run
32260  "view_url": string   // relative URL to the report page
32261}</div></details>
32262          <p class="curl-heading">Example</p>
32263          <div class="curl-wrap">
32264            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32265  -H "Authorization: Bearer $SLOC_API_KEY" \
32266  -H "Content-Type: application/json" \
32267  -d @result.json \
32268  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32269            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32270          </div>
32271        </div>
32272      </div>
32273    </div>
32274
32275    <!-- Artifact Download -->
32276    <div class="section">
32277      <h2 class="section-title">Artifact Download</h2>
32278
32279      <div class="ep-card">
32280        <div class="ep-header">
32281          <span class="method get">GET</span>
32282          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32283          <span class="auth-badge protected">Protected</span>
32284          <span class="ep-desc">Download or view a scan artifact</span>
32285          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32286        </div>
32287        <div class="ep-body">
32288          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32289          <p class="params-heading">Path Parameters</p>
32290          <table class="params">
32291            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32292            <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>
32293            <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>
32294          </table>
32295          <p class="params-heading">Query Parameters</p>
32296          <table class="params">
32297            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32298            <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>
32299          </table>
32300          <p class="curl-heading">Example — download JSON result</p>
32301          <div class="curl-wrap">
32302            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32303  -o result.json \
32304  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
32305            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32306          </div>
32307        </div>
32308      </div>
32309    </div>
32310
32311    <!-- Embed Widget -->
32312    <div class="section">
32313      <h2 class="section-title">Embed Widget</h2>
32314
32315      <div class="ep-card">
32316        <div class="ep-header">
32317          <span class="method get">GET</span>
32318          <span class="ep-path">/embed/summary</span>
32319          <span class="auth-badge protected">Protected</span>
32320          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32321          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32322        </div>
32323        <div class="ep-body">
32324          <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>
32325          <p class="params-heading">Query Parameters</p>
32326          <table class="params">
32327            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32328            <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>
32329            <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>
32330          </table>
32331          <p class="curl-heading">Example</p>
32332          <div class="curl-wrap">
32333            <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"
32334        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
32335            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32336          </div>
32337        </div>
32338      </div>
32339    </div>
32340
32341    <!-- Confluence Integration -->
32342    <div class="section">
32343      <h2 class="section-title">Confluence Integration</h2>
32344
32345      <div class="ep-card">
32346        <div class="ep-header">
32347          <span class="method get">GET</span>
32348          <span class="ep-path">/api/confluence/config</span>
32349          <span class="auth-badge protected">Protected</span>
32350          <span class="ep-desc">Get current Confluence configuration</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">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32355          <details class="schema"><summary>Response schema</summary>
32356<div class="schema-block">{
32357  "configured":     boolean,
32358  "tier":           "cloud" | "server",
32359  "base_url":       string,
32360  "username":       string,
32361  "api_token_set":  boolean,
32362  "space_key":      string,
32363  "parent_page_id": string | null,
32364  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
32365}</div></details>
32366          <p class="curl-heading">Example</p>
32367          <div class="curl-wrap">
32368            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32369  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32370            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32371          </div>
32372        </div>
32373      </div>
32374
32375      <div class="ep-card">
32376        <div class="ep-header">
32377          <span class="method post">POST</span>
32378          <span class="ep-path">/api/confluence/config</span>
32379          <span class="auth-badge protected">Protected</span>
32380          <span class="ep-desc">Save Confluence configuration</span>
32381          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32382        </div>
32383        <div class="ep-body">
32384          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32385          <p class="params-heading">Request Body (application/json)</p>
32386          <table class="params">
32387            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32388            <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>
32389            <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>
32390            <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>
32391            <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>
32392            <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>
32393            <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>
32394            <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>
32395          </table>
32396          <details class="schema"><summary>Response schema</summary>
32397<div class="schema-block">{ "ok": true }</div></details>
32398          <p class="curl-heading">Example</p>
32399          <div class="curl-wrap">
32400            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32401  -H "Authorization: Bearer $SLOC_API_KEY" \
32402  -H "Content-Type: application/json" \
32403  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32404  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32405            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32406          </div>
32407        </div>
32408      </div>
32409
32410      <div class="ep-card">
32411        <div class="ep-header">
32412          <span class="method post">POST</span>
32413          <span class="ep-path">/api/confluence/test</span>
32414          <span class="auth-badge protected">Protected</span>
32415          <span class="ep-desc">Test Confluence connection</span>
32416          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32417        </div>
32418        <div class="ep-body">
32419          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32420          <details class="schema"><summary>Response schema</summary>
32421<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32422          <p class="curl-heading">Example</p>
32423          <div class="curl-wrap">
32424            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32425  -H "Authorization: Bearer $SLOC_API_KEY" \
32426  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32427            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32428          </div>
32429        </div>
32430      </div>
32431
32432      <div class="ep-card">
32433        <div class="ep-header">
32434          <span class="method post">POST</span>
32435          <span class="ep-path">/api/confluence/post</span>
32436          <span class="auth-badge protected">Protected</span>
32437          <span class="ep-desc">Publish a scan report to Confluence</span>
32438          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32439        </div>
32440        <div class="ep-body">
32441          <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>
32442          <p class="params-heading">Request Body (application/json)</p>
32443          <table class="params">
32444            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32445            <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>
32446            <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>
32447            <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>
32448          </table>
32449          <details class="schema"><summary>Response schema</summary>
32450<div class="schema-block">// 200 OK
32451{ "ok": true, "page_id": string }
32452
32453// 400 / 502 on error
32454{ "ok": false, "error": string }</div></details>
32455          <p class="curl-heading">Example</p>
32456          <div class="curl-wrap">
32457            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32458  -H "Authorization: Bearer $SLOC_API_KEY" \
32459  -H "Content-Type: application/json" \
32460  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
32461  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32462            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32463          </div>
32464        </div>
32465      </div>
32466
32467      <div class="ep-card">
32468        <div class="ep-header">
32469          <span class="method get">GET</span>
32470          <span class="ep-path">/api/confluence/wiki-markup</span>
32471          <span class="auth-badge protected">Protected</span>
32472          <span class="ep-desc">Get Confluence wiki markup for a run</span>
32473          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32474        </div>
32475        <div class="ep-body">
32476          <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>
32477          <p class="params-heading">Query Parameters</p>
32478          <table class="params">
32479            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32480            <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>
32481          </table>
32482          <p class="curl-heading">Example</p>
32483          <div class="curl-wrap">
32484            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32485  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
32486            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32487          </div>
32488        </div>
32489      </div>
32490    </div>
32491
32492    <!-- Authentication -->
32493    <div class="section">
32494      <h2 class="section-title">Authentication</h2>
32495      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32496
32497      <div class="ep-card">
32498        <div class="ep-header">
32499          <span class="method get">GET</span>
32500          <span class="ep-path">/auth/login</span>
32501          <span class="auth-badge public">Public</span>
32502          <span class="ep-desc">Login page</span>
32503          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32504        </div>
32505        <div class="ep-body">
32506          <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>
32507          <p class="params-heading">Query Parameters</p>
32508          <table class="params">
32509            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32510            <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>
32511            <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>
32512          </table>
32513        </div>
32514      </div>
32515
32516      <div class="ep-card">
32517        <div class="ep-header">
32518          <span class="method post">POST</span>
32519          <span class="ep-path">/auth/login</span>
32520          <span class="auth-badge public">Public</span>
32521          <span class="ep-desc">Submit credentials and get a session cookie</span>
32522          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32523        </div>
32524        <div class="ep-body">
32525          <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>
32526          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32527          <table class="params">
32528            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32529            <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>
32530            <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>
32531          </table>
32532          <p class="curl-heading">Example</p>
32533          <div class="curl-wrap">
32534            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32535  -d "key=$SLOC_API_KEY&amp;next=/" \
32536  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32537            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32538          </div>
32539        </div>
32540      </div>
32541    </div>
32542
32543    <!-- Coverage Suggestion -->
32544    <div class="section">
32545      <h2 class="section-title">Coverage Suggestion</h2>
32546
32547      <div class="ep-card">
32548        <div class="ep-header">
32549          <span class="method get">GET</span>
32550          <span class="ep-path">/api/suggest-coverage</span>
32551          <span class="auth-badge protected">Protected</span>
32552          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32553          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32554        </div>
32555        <div class="ep-body">
32556          <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>
32557          <p class="params-heading">Query Parameters</p>
32558          <table class="params">
32559            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32560            <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>
32561          </table>
32562          <details class="schema"><summary>Response schema</summary>
32563<div class="schema-block">{
32564  "found": string | null,  // absolute path to the coverage file, if detected
32565  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32566  "hint":  string | null   // shell command to generate coverage if not found
32567}</div></details>
32568          <p class="curl-heading">Example</p>
32569          <div class="curl-wrap">
32570            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32571  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32572            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32573          </div>
32574        </div>
32575      </div>
32576    </div>
32577
32578  </div>
32579
32580  <footer class="site-footer">
32581    local code analysis - metrics, history and reports
32582    &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>
32583    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32584    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32585    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32586    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32587  </footer>
32588
32589  <script nonce="{{ csp_nonce }}">
32590    (function () {
32591      var base = window.location.origin;
32592      document.getElementById('base-url').textContent = base;
32593      document.querySelectorAll('.base-url-slot').forEach(function (el) {
32594        el.textContent = base;
32595      });
32596
32597      document.querySelectorAll('.ep-header').forEach(function (hdr) {
32598        hdr.addEventListener('click', function () {
32599          hdr.closest('.ep-card').classList.toggle('open');
32600        });
32601      });
32602
32603      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32604        btn.addEventListener('click', function () {
32605          var targetId = btn.dataset.target;
32606          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32607          if (!pre) return;
32608          navigator.clipboard.writeText(pre.textContent).then(function () {
32609            btn.textContent = 'Copied!';
32610            btn.classList.add('copied');
32611            setTimeout(function () {
32612              btn.textContent = 'Copy';
32613              btn.classList.remove('copied');
32614            }, 2000);
32615          });
32616        });
32617      });
32618
32619      var storageKey = 'oxide-sloc-theme';
32620      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32621      var themeBtn = document.getElementById('theme-toggle');
32622      if (themeBtn) {
32623        themeBtn.addEventListener('click', function () {
32624          var dark = document.body.classList.toggle('dark-theme');
32625          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32626        });
32627      }
32628      (function() {
32629        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'}];
32630        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);});}
32631        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32632        var btn=document.getElementById('settings-btn');if(!btn)return;
32633        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32634        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>';
32635        document.body.appendChild(m);
32636        var g=document.getElementById('scheme-grid');
32637        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);});
32638        var cl=document.getElementById('settings-close');
32639        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);});})();
32640        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');});
32641        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32642        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32643      })();
32644      (function randomizeWatermarks() {
32645        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32646        if (!wms.length) return;
32647        var placed = [];
32648        function tooClose(top, left) {
32649          for (var i = 0; i < placed.length; i++) {
32650            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32651            if (dt < 16 && dl < 12) return true;
32652          }
32653          return false;
32654        }
32655        function pick(leftBand) {
32656          for (var attempt = 0; attempt < 50; attempt++) {
32657            var top = Math.random() * 88 + 2;
32658            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32659            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32660          }
32661          var top = Math.random() * 88 + 2;
32662          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32663          placed.push([top, left]); return [top, left];
32664        }
32665        var half = Math.floor(wms.length / 2);
32666        wms.forEach(function (img, i) {
32667          var pos = pick(i < half);
32668          var size = Math.floor(Math.random() * 100 + 120);
32669          var rot = (Math.random() * 360).toFixed(1);
32670          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32671          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;
32672        });
32673      })();
32674      (function spawnCodeParticles() {
32675        var container = document.getElementById('code-particles');
32676        if (!container) return;
32677        var snippets = [
32678          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32679          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32680          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32681          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32682          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32683        ];
32684        var count = 38;
32685        for (var i = 0; i < count; i++) {
32686          (function(idx) {
32687            var el = document.createElement('span');
32688            el.className = 'code-particle';
32689            el.textContent = snippets[idx % snippets.length];
32690            var left = Math.random() * 94 + 2;
32691            var top = Math.random() * 88 + 6;
32692            var dur = (Math.random() * 10 + 9).toFixed(1);
32693            var delay = (Math.random() * 18).toFixed(1);
32694            var rot = (Math.random() * 26 - 13).toFixed(1);
32695            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32696            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32697            container.appendChild(el);
32698          })(i);
32699        }
32700      })();
32701    }());
32702  </script>
32703</body>
32704</html>
32705"##,
32706    ext = "html"
32707)]
32708struct ApiDocsTemplate {
32709    has_api_key: bool,
32710    csp_nonce: String,
32711    version: &'static str,
32712}
32713
32714#[cfg(test)]
32715mod form_config_tests {
32716    use super::*;
32717    use sloc_config::{
32718        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32719    };
32720
32721    fn blank_form() -> AnalyzeForm {
32722        AnalyzeForm {
32723            path: ".".to_string(),
32724            git_repo: None,
32725            git_ref: None,
32726            mixed_line_policy: None,
32727            python_docstrings_as_comments: None,
32728            generated_file_detection: None,
32729            minified_file_detection: None,
32730            vendor_directory_detection: None,
32731            include_lockfiles: None,
32732            binary_file_behavior: None,
32733            output_dir: None,
32734            report_title: None,
32735            report_header_footer: None,
32736            include_globs: None,
32737            exclude_globs: None,
32738            submodule_breakdown: None,
32739            coverage_file: None,
32740            continuation_line_policy: None,
32741            blank_in_block_comment_policy: None,
32742            count_compiler_directives: None,
32743            style_col_threshold: None,
32744            style_analysis_enabled: None,
32745            style_score_threshold: None,
32746            style_lang_scope: None,
32747            cocomo_mode: None,
32748            complexity_alert: None,
32749            exclude_duplicates: None,
32750            activity_window: None,
32751        }
32752    }
32753
32754    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32755        let mut cfg = sloc_config::AppConfig::default();
32756        apply_form_to_config(&mut cfg, form);
32757        cfg
32758    }
32759
32760    // ── activity_window (git hotspots — on by default) ──
32761
32762    #[test]
32763    fn extract_long_commit_picks_super_repo_by_short_prefix() {
32764        // A pretty-printed JSON tail containing several submodule git_commit_long
32765        // values plus the super-repo's; the helper must return the one whose hash
32766        // starts with the known short SHA, ignoring the others and any null value.
32767        let dir = tempfile::tempdir().unwrap();
32768        let path = dir.path().join("result.json");
32769        let body = r#"{
32770  "submodules": [
32771    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32772    { "git_commit_long": null }
32773  ],
32774  "git_commit_short": "4c2cd9b",
32775  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32776}"#;
32777        std::fs::write(&path, body).unwrap();
32778        assert_eq!(
32779            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32780            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32781        );
32782        // No match for an unrelated short SHA, and empty short yields None.
32783        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32784        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32785    }
32786
32787    #[test]
32788    fn activity_window_defaults_on_when_field_blank() {
32789        // Blank form field keeps the config default (90 days).
32790        let cfg = apply(&blank_form());
32791        assert_eq!(cfg.analysis.activity_window_days, Some(90));
32792    }
32793
32794    #[test]
32795    fn activity_window_override_sets_days() {
32796        let mut form = blank_form();
32797        form.activity_window = Some("30".to_string());
32798        let cfg = apply(&form);
32799        assert_eq!(cfg.analysis.activity_window_days, Some(30));
32800    }
32801
32802    #[test]
32803    fn activity_window_zero_disables() {
32804        // An explicit 0 from the form disables hotspots (overrides the default-on).
32805        let mut form = blank_form();
32806        form.activity_window = Some("0".to_string());
32807        let cfg = apply(&form);
32808        assert_eq!(cfg.analysis.activity_window_days, Some(0));
32809    }
32810
32811    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
32812
32813    #[test]
32814    fn python_docstrings_false_when_unchecked() {
32815        // Checkbox absent in form data (unchecked) → field must be false.
32816        let cfg = apply(&blank_form());
32817        assert!(
32818            !cfg.analysis.python_docstrings_as_comments,
32819            "absent python_docstrings_as_comments must map to false"
32820        );
32821    }
32822
32823    #[test]
32824    fn python_docstrings_true_when_checked() {
32825        // Browser sends "on" (no value= attr on the checkbox).
32826        let mut form = blank_form();
32827        form.python_docstrings_as_comments = Some("on".to_string());
32828        let cfg = apply(&form);
32829        assert!(cfg.analysis.python_docstrings_as_comments);
32830    }
32831
32832    #[test]
32833    fn python_docstrings_true_for_any_non_none_value() {
32834        // The handler uses .is_some() — any non-None value means "checked".
32835        let mut form = blank_form();
32836        form.python_docstrings_as_comments = Some("true".to_string());
32837        assert!(apply(&form).analysis.python_docstrings_as_comments);
32838    }
32839
32840    // ── submodule_breakdown (checkbox with value="enabled") ──
32841
32842    #[test]
32843    fn submodule_breakdown_false_when_unchecked() {
32844        let cfg = apply(&blank_form());
32845        assert!(
32846            !cfg.discovery.submodule_breakdown,
32847            "absent submodule_breakdown must map to false"
32848        );
32849    }
32850
32851    #[test]
32852    fn submodule_breakdown_true_when_value_enabled() {
32853        let mut form = blank_form();
32854        form.submodule_breakdown = Some("enabled".to_string());
32855        assert!(apply(&form).discovery.submodule_breakdown);
32856    }
32857
32858    #[test]
32859    fn submodule_breakdown_false_for_wrong_value() {
32860        // If somehow a value other than "enabled" is sent, it must still be false.
32861        let mut form = blank_form();
32862        form.submodule_breakdown = Some("on".to_string());
32863        assert!(
32864            !apply(&form).discovery.submodule_breakdown,
32865            "submodule_breakdown only becomes true for the exact value 'enabled'"
32866        );
32867    }
32868
32869    // ── generated_file_detection (select: "enabled" | "disabled") ──
32870
32871    #[test]
32872    fn generated_detection_true_when_enabled() {
32873        let mut form = blank_form();
32874        form.generated_file_detection = Some("enabled".to_string());
32875        assert!(apply(&form).analysis.generated_file_detection);
32876    }
32877
32878    #[test]
32879    fn generated_detection_false_when_disabled() {
32880        let mut form = blank_form();
32881        form.generated_file_detection = Some("disabled".to_string());
32882        assert!(!apply(&form).analysis.generated_file_detection);
32883    }
32884
32885    #[test]
32886    fn generated_detection_true_when_absent() {
32887        // None != Some("disabled") → true (safe default)
32888        assert!(
32889            apply(&blank_form()).analysis.generated_file_detection,
32890            "absent field must default to true (detection on)"
32891        );
32892    }
32893
32894    // ── minified_file_detection ──
32895
32896    #[test]
32897    fn minified_detection_false_when_disabled() {
32898        let mut form = blank_form();
32899        form.minified_file_detection = Some("disabled".to_string());
32900        assert!(!apply(&form).analysis.minified_file_detection);
32901    }
32902
32903    #[test]
32904    fn minified_detection_true_when_enabled() {
32905        let mut form = blank_form();
32906        form.minified_file_detection = Some("enabled".to_string());
32907        assert!(apply(&form).analysis.minified_file_detection);
32908    }
32909
32910    #[test]
32911    fn minified_detection_true_when_absent() {
32912        assert!(apply(&blank_form()).analysis.minified_file_detection);
32913    }
32914
32915    // ── vendor_directory_detection ──
32916
32917    #[test]
32918    fn vendor_detection_false_when_disabled() {
32919        let mut form = blank_form();
32920        form.vendor_directory_detection = Some("disabled".to_string());
32921        assert!(!apply(&form).analysis.vendor_directory_detection);
32922    }
32923
32924    #[test]
32925    fn vendor_detection_true_when_enabled() {
32926        let mut form = blank_form();
32927        form.vendor_directory_detection = Some("enabled".to_string());
32928        assert!(apply(&form).analysis.vendor_directory_detection);
32929    }
32930
32931    #[test]
32932    fn vendor_detection_true_when_absent() {
32933        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32934    }
32935
32936    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32937
32938    #[test]
32939    fn lockfiles_false_when_absent() {
32940        // None == Some("enabled") is false → lockfiles off (correct safe default)
32941        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32942    }
32943
32944    #[test]
32945    fn lockfiles_false_when_disabled() {
32946        let mut form = blank_form();
32947        form.include_lockfiles = Some("disabled".to_string());
32948        assert!(!apply(&form).analysis.include_lockfiles);
32949    }
32950
32951    #[test]
32952    fn lockfiles_true_when_enabled() {
32953        let mut form = blank_form();
32954        form.include_lockfiles = Some("enabled".to_string());
32955        assert!(apply(&form).analysis.include_lockfiles);
32956    }
32957
32958    // ── count_compiler_directives ──
32959
32960    #[test]
32961    fn compiler_directives_true_when_absent() {
32962        assert!(
32963            apply(&blank_form()).analysis.count_compiler_directives,
32964            "absent count_compiler_directives must default to true"
32965        );
32966    }
32967
32968    #[test]
32969    fn compiler_directives_true_when_enabled() {
32970        let mut form = blank_form();
32971        form.count_compiler_directives = Some("enabled".to_string());
32972        assert!(apply(&form).analysis.count_compiler_directives);
32973    }
32974
32975    #[test]
32976    fn compiler_directives_false_when_disabled() {
32977        let mut form = blank_form();
32978        form.count_compiler_directives = Some("disabled".to_string());
32979        assert!(!apply(&form).analysis.count_compiler_directives);
32980    }
32981
32982    // ── mixed_line_policy (enum select) ──
32983
32984    #[test]
32985    fn mixed_policy_unchanged_when_absent() {
32986        // None → if-let does nothing → stays at config default (CodeOnly)
32987        assert_eq!(
32988            apply(&blank_form()).analysis.mixed_line_policy,
32989            MixedLinePolicy::CodeOnly
32990        );
32991    }
32992
32993    #[test]
32994    fn mixed_policy_code_only() {
32995        let mut form = blank_form();
32996        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32997        assert_eq!(
32998            apply(&form).analysis.mixed_line_policy,
32999            MixedLinePolicy::CodeOnly
33000        );
33001    }
33002
33003    #[test]
33004    fn mixed_policy_code_and_comment() {
33005        let mut form = blank_form();
33006        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33007        assert_eq!(
33008            apply(&form).analysis.mixed_line_policy,
33009            MixedLinePolicy::CodeAndComment
33010        );
33011    }
33012
33013    #[test]
33014    fn mixed_policy_comment_only() {
33015        let mut form = blank_form();
33016        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33017        assert_eq!(
33018            apply(&form).analysis.mixed_line_policy,
33019            MixedLinePolicy::CommentOnly
33020        );
33021    }
33022
33023    #[test]
33024    fn mixed_policy_separate_mixed_category() {
33025        let mut form = blank_form();
33026        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33027        assert_eq!(
33028            apply(&form).analysis.mixed_line_policy,
33029            MixedLinePolicy::SeparateMixedCategory
33030        );
33031    }
33032
33033    // ── binary_file_behavior (enum select) ──
33034
33035    #[test]
33036    fn binary_behavior_skip_when_absent() {
33037        assert_eq!(
33038            apply(&blank_form()).analysis.binary_file_behavior,
33039            BinaryFileBehavior::Skip
33040        );
33041    }
33042
33043    #[test]
33044    fn binary_behavior_skip() {
33045        let mut form = blank_form();
33046        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33047        assert_eq!(
33048            apply(&form).analysis.binary_file_behavior,
33049            BinaryFileBehavior::Skip
33050        );
33051    }
33052
33053    #[test]
33054    fn binary_behavior_fail() {
33055        let mut form = blank_form();
33056        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33057        assert_eq!(
33058            apply(&form).analysis.binary_file_behavior,
33059            BinaryFileBehavior::Fail
33060        );
33061    }
33062
33063    // ── continuation_line_policy (enum select) ──
33064
33065    #[test]
33066    fn continuation_policy_each_physical_when_absent() {
33067        assert_eq!(
33068            apply(&blank_form()).analysis.continuation_line_policy,
33069            ContinuationLinePolicy::EachPhysicalLine
33070        );
33071    }
33072
33073    #[test]
33074    fn continuation_policy_collapse_to_logical() {
33075        let mut form = blank_form();
33076        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33077        assert_eq!(
33078            apply(&form).analysis.continuation_line_policy,
33079            ContinuationLinePolicy::CollapseToLogical
33080        );
33081    }
33082
33083    // ── blank_in_block_comment_policy (enum select) ──
33084
33085    #[test]
33086    fn blank_in_block_comment_count_as_comment_when_absent() {
33087        assert_eq!(
33088            apply(&blank_form()).analysis.blank_in_block_comment_policy,
33089            BlankInBlockCommentPolicy::CountAsComment
33090        );
33091    }
33092
33093    #[test]
33094    fn blank_in_block_comment_count_as_blank() {
33095        let mut form = blank_form();
33096        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33097        assert_eq!(
33098            apply(&form).analysis.blank_in_block_comment_policy,
33099            BlankInBlockCommentPolicy::CountAsBlank
33100        );
33101    }
33102
33103    // ── style_col_threshold ──
33104
33105    #[test]
33106    fn style_threshold_80() {
33107        let mut form = blank_form();
33108        form.style_col_threshold = Some("80".to_string());
33109        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33110    }
33111
33112    #[test]
33113    fn style_threshold_100() {
33114        let mut form = blank_form();
33115        form.style_col_threshold = Some("100".to_string());
33116        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33117    }
33118
33119    #[test]
33120    fn style_threshold_120() {
33121        let mut form = blank_form();
33122        form.style_col_threshold = Some("120".to_string());
33123        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33124    }
33125
33126    #[test]
33127    fn style_threshold_invalid_value_leaves_default() {
33128        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
33129        let mut cfg = sloc_config::AppConfig::default();
33130        let mut form = blank_form();
33131        form.style_col_threshold = Some("42".to_string());
33132        apply_form_to_config(&mut cfg, &form);
33133        assert_eq!(
33134            cfg.analysis.style_col_threshold, 80,
33135            "invalid threshold must not change config"
33136        );
33137    }
33138
33139    #[test]
33140    fn style_threshold_non_numeric_leaves_default() {
33141        let mut cfg = sloc_config::AppConfig::default();
33142        let mut form = blank_form();
33143        form.style_col_threshold = Some("large".to_string());
33144        apply_form_to_config(&mut cfg, &form);
33145        assert_eq!(cfg.analysis.style_col_threshold, 80);
33146    }
33147
33148    #[test]
33149    fn style_threshold_zero_leaves_default() {
33150        let mut cfg = sloc_config::AppConfig::default();
33151        let mut form = blank_form();
33152        form.style_col_threshold = Some("0".to_string());
33153        apply_form_to_config(&mut cfg, &form);
33154        assert_eq!(cfg.analysis.style_col_threshold, 80);
33155    }
33156
33157    #[test]
33158    fn style_threshold_absent_leaves_default() {
33159        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33160    }
33161
33162    // ── style_score_threshold ──
33163
33164    #[test]
33165    fn style_score_threshold_zero_when_absent() {
33166        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33167    }
33168
33169    #[test]
33170    fn style_score_threshold_set_to_valid_value() {
33171        let mut form = blank_form();
33172        form.style_score_threshold = Some("70".to_string());
33173        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33174    }
33175
33176    #[test]
33177    fn style_score_threshold_clamps_to_100_when_over() {
33178        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
33179        let mut form = blank_form();
33180        form.style_score_threshold = Some("200".to_string());
33181        assert_eq!(
33182            apply(&form).analysis.style_score_threshold,
33183            100,
33184            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33185        );
33186    }
33187
33188    // ── coverage_file ──
33189
33190    #[test]
33191    fn coverage_file_none_when_absent() {
33192        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33193    }
33194
33195    #[test]
33196    fn coverage_file_none_when_whitespace_only() {
33197        let mut form = blank_form();
33198        form.coverage_file = Some("   ".to_string());
33199        assert!(
33200            apply(&form).analysis.coverage_file.is_none(),
33201            "whitespace-only coverage_file must be treated as None"
33202        );
33203    }
33204
33205    #[test]
33206    fn coverage_file_set_when_non_empty() {
33207        let mut form = blank_form();
33208        form.coverage_file = Some("coverage/lcov.info".to_string());
33209        assert_eq!(
33210            apply(&form).analysis.coverage_file,
33211            Some(std::path::PathBuf::from("coverage/lcov.info"))
33212        );
33213    }
33214
33215    #[test]
33216    fn coverage_file_trims_whitespace() {
33217        let mut form = blank_form();
33218        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
33219        assert_eq!(
33220            apply(&form).analysis.coverage_file,
33221            Some(std::path::PathBuf::from("coverage/lcov.info"))
33222        );
33223    }
33224
33225    // ── report_title ──
33226
33227    #[test]
33228    fn report_title_unchanged_when_absent() {
33229        let original = sloc_config::AppConfig::default().reporting.report_title;
33230        assert_eq!(apply(&blank_form()).reporting.report_title, original);
33231    }
33232
33233    #[test]
33234    fn report_title_unchanged_when_whitespace_only() {
33235        let original = sloc_config::AppConfig::default().reporting.report_title;
33236        let mut form = blank_form();
33237        form.report_title = Some("   ".to_string());
33238        assert_eq!(
33239            apply(&form).reporting.report_title,
33240            original,
33241            "whitespace-only title must not overwrite the default"
33242        );
33243    }
33244
33245    #[test]
33246    fn report_title_updated_and_trimmed() {
33247        let mut form = blank_form();
33248        form.report_title = Some("  My Project  ".to_string());
33249        assert_eq!(apply(&form).reporting.report_title, "My Project");
33250    }
33251
33252    // ── report_header_footer ──
33253
33254    #[test]
33255    fn header_footer_none_when_absent() {
33256        assert!(
33257            apply(&blank_form())
33258                .reporting
33259                .report_header_footer
33260                .is_none()
33261        );
33262    }
33263
33264    #[test]
33265    fn header_footer_none_when_whitespace_only() {
33266        let mut form = blank_form();
33267        form.report_header_footer = Some("  ".to_string());
33268        assert!(apply(&form).reporting.report_header_footer.is_none());
33269    }
33270
33271    #[test]
33272    fn header_footer_set_and_trimmed() {
33273        let mut form = blank_form();
33274        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
33275        assert_eq!(
33276            apply(&form).reporting.report_header_footer,
33277            Some("Confidential — Internal Use".to_string())
33278        );
33279    }
33280
33281    // ── include_globs / exclude_globs ──
33282
33283    #[test]
33284    fn include_globs_empty_when_absent() {
33285        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33286    }
33287
33288    #[test]
33289    fn include_globs_newline_separated() {
33290        let mut form = blank_form();
33291        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33292        assert_eq!(
33293            apply(&form).discovery.include_globs,
33294            vec!["src/**/*.rs", "tests/**/*.rs"]
33295        );
33296    }
33297
33298    #[test]
33299    fn exclude_globs_comma_separated() {
33300        let mut form = blank_form();
33301        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33302        assert_eq!(
33303            apply(&form).discovery.exclude_globs,
33304            vec!["vendor/**", "node_modules/**"]
33305        );
33306    }
33307
33308    #[test]
33309    fn globs_mixed_separators() {
33310        let mut form = blank_form();
33311        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33312        assert_eq!(
33313            apply(&form).discovery.exclude_globs,
33314            vec!["a/**", "b/**", "c/**"]
33315        );
33316    }
33317
33318    // ── split_patterns unit tests ──
33319
33320    #[test]
33321    fn split_patterns_none_is_empty() {
33322        assert!(split_patterns(None).is_empty());
33323    }
33324
33325    #[test]
33326    fn split_patterns_empty_string_is_empty() {
33327        assert!(split_patterns(Some("")).is_empty());
33328    }
33329
33330    #[test]
33331    fn split_patterns_whitespace_only_is_empty() {
33332        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
33333    }
33334
33335    #[test]
33336    fn split_patterns_newlines() {
33337        assert_eq!(
33338            split_patterns(Some("a/**\nb/**\nc/**")),
33339            vec!["a/**", "b/**", "c/**"]
33340        );
33341    }
33342
33343    #[test]
33344    fn split_patterns_commas() {
33345        assert_eq!(
33346            split_patterns(Some("a/**,b/**,c/**")),
33347            vec!["a/**", "b/**", "c/**"]
33348        );
33349    }
33350
33351    #[test]
33352    fn split_patterns_mixed() {
33353        assert_eq!(
33354            split_patterns(Some("a/**\nb/**,c/**")),
33355            vec!["a/**", "b/**", "c/**"]
33356        );
33357    }
33358
33359    #[test]
33360    fn split_patterns_trims_whitespace() {
33361        assert_eq!(
33362            split_patterns(Some("  a/**  \n  b/**  ")),
33363            vec!["a/**", "b/**"]
33364        );
33365    }
33366
33367    #[test]
33368    fn split_patterns_filters_empty_entries() {
33369        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33370    }
33371
33372    #[test]
33373    fn split_patterns_single_entry() {
33374        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33375    }
33376}
33377
33378#[cfg(test)]
33379mod utility_tests {
33380    use super::*;
33381    use std::net::IpAddr;
33382    use std::time::Duration;
33383
33384    // ── sanitize_project_label ────────────────────────────────────────────────
33385
33386    #[test]
33387    fn sanitize_simple_name() {
33388        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33389    }
33390
33391    #[test]
33392    fn sanitize_uppercased_lowercased() {
33393        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33394    }
33395
33396    #[test]
33397    fn sanitize_path_extracts_filename() {
33398        assert_eq!(
33399            sanitize_project_label("/home/user/my-project"),
33400            "my-project"
33401        );
33402    }
33403
33404    #[test]
33405    fn sanitize_path_uses_last_component() {
33406        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33407    }
33408
33409    #[test]
33410    fn sanitize_spaces_become_hyphens() {
33411        assert_eq!(sanitize_project_label("my project"), "my-project");
33412    }
33413
33414    #[test]
33415    fn sanitize_non_ascii_become_hyphens() {
33416        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33417    }
33418
33419    #[test]
33420    fn sanitize_all_special_chars_gives_project() {
33421        assert_eq!(sanitize_project_label("!@#$%^"), "project");
33422    }
33423
33424    #[test]
33425    fn sanitize_empty_string_gives_project() {
33426        assert_eq!(sanitize_project_label(""), "project");
33427    }
33428
33429    #[test]
33430    fn sanitize_leading_trailing_hyphens_stripped() {
33431        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33432    }
33433
33434    #[test]
33435    fn sanitize_alphanumeric_preserved() {
33436        assert_eq!(sanitize_project_label("repo123"), "repo123");
33437    }
33438
33439    #[test]
33440    fn sanitize_dots_become_hyphens() {
33441        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33442    }
33443
33444    #[test]
33445    fn sanitize_mixed_slashes_uses_filename() {
33446        // The Windows path separator — on all platforms Path::file_name still works
33447        assert_eq!(sanitize_project_label("project-name"), "project-name");
33448    }
33449
33450    // ── IpRateLimiter ─────────────────────────────────────────────────────────
33451
33452    #[test]
33453    fn rate_limiter_allows_first_request() {
33454        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33455        let ip: IpAddr = "127.0.0.1".parse().unwrap();
33456        assert!(rl.is_allowed(ip));
33457    }
33458
33459    #[test]
33460    fn rate_limiter_blocks_after_limit_reached() {
33461        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33462        let ip: IpAddr = "10.0.0.1".parse().unwrap();
33463        assert!(rl.is_allowed(ip));
33464        assert!(rl.is_allowed(ip));
33465        assert!(rl.is_allowed(ip));
33466        assert!(!rl.is_allowed(ip), "4th request must be blocked");
33467    }
33468
33469    #[test]
33470    fn rate_limiter_allows_requests_up_to_limit() {
33471        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33472        let ip: IpAddr = "10.0.0.2".parse().unwrap();
33473        for _ in 0..5 {
33474            assert!(rl.is_allowed(ip));
33475        }
33476        assert!(!rl.is_allowed(ip), "6th request must be blocked");
33477    }
33478
33479    #[test]
33480    fn rate_limiter_different_ips_are_independent() {
33481        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33482        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33483        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33484        assert!(rl.is_allowed(ip1));
33485        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33486        assert!(rl.is_allowed(ip2), "ip2 must be independent");
33487    }
33488
33489    #[test]
33490    fn rate_limiter_auth_failure_not_locked_below_threshold() {
33491        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33492        let ip: IpAddr = "10.0.0.3".parse().unwrap();
33493        rl.record_auth_failure(ip);
33494        rl.record_auth_failure(ip);
33495        assert!(
33496            !rl.is_auth_locked_out(ip),
33497            "not locked at 2 failures when threshold is 3"
33498        );
33499    }
33500
33501    #[test]
33502    fn rate_limiter_auth_failure_locked_at_threshold() {
33503        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33504        let ip: IpAddr = "10.0.0.4".parse().unwrap();
33505        rl.record_auth_failure(ip);
33506        rl.record_auth_failure(ip);
33507        rl.record_auth_failure(ip);
33508        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33509    }
33510
33511    #[test]
33512    fn rate_limiter_auth_failure_different_ips_independent() {
33513        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33514        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33515        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33516        rl.record_auth_failure(ip1);
33517        rl.record_auth_failure(ip1);
33518        assert!(rl.is_auth_locked_out(ip1));
33519        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33520    }
33521
33522    #[test]
33523    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33524        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33525        let ip: IpAddr = "127.0.0.2".parse().unwrap();
33526        for _ in 0..100 {
33527            assert!(rl.is_allowed(ip));
33528        }
33529    }
33530
33531    // ── strip_unc_prefix ──────────────────────────────────────────────────────
33532
33533    #[test]
33534    fn strip_unc_plain_path_unchanged() {
33535        let p = PathBuf::from("C:\\Users\\user\\project");
33536        let result = strip_unc_prefix(p.clone());
33537        assert_eq!(result, p);
33538    }
33539
33540    #[test]
33541    fn strip_unc_with_drive_prefix_stripped() {
33542        let p = PathBuf::from(r"\\?\C:\Users\user\project");
33543        let result = strip_unc_prefix(p);
33544        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33545    }
33546
33547    #[test]
33548    fn strip_unc_with_network_prefix_stripped() {
33549        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33550        let result = strip_unc_prefix(p);
33551        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33552    }
33553
33554    #[test]
33555    fn strip_unc_linux_path_unchanged() {
33556        let p = PathBuf::from("/home/user/project");
33557        let result = strip_unc_prefix(p.clone());
33558        assert_eq!(result, p);
33559    }
33560
33561    // ── remote_to_commit_url ──────────────────────────────────────────────────
33562
33563    #[test]
33564    fn remote_to_commit_url_github_https() {
33565        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33566        assert_eq!(
33567            url,
33568            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33569        );
33570    }
33571
33572    #[test]
33573    fn remote_to_commit_url_github_ssh() {
33574        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33575        assert_eq!(
33576            url,
33577            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33578        );
33579    }
33580
33581    #[test]
33582    fn remote_to_commit_url_gitlab_uses_dash_commit() {
33583        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33584        assert_eq!(
33585            url,
33586            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33587        );
33588    }
33589
33590    #[test]
33591    fn remote_to_commit_url_bitbucket_uses_commits() {
33592        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33593        assert_eq!(
33594            url,
33595            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33596        );
33597    }
33598
33599    #[test]
33600    fn remote_to_commit_url_unknown_scheme_returns_none() {
33601        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33602        assert!(url.is_none());
33603    }
33604
33605    #[test]
33606    fn remote_to_commit_url_ssh_gitlab() {
33607        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33608        assert!(url.is_some());
33609        let u = url.unwrap();
33610        assert!(
33611            u.contains("/-/commit/sha123"),
33612            "gitlab ssh must use /-/commit/"
33613        );
33614    }
33615
33616    // ── git_clone_dest ────────────────────────────────────────────────────────
33617
33618    #[test]
33619    fn git_clone_dest_github_url_produces_safe_name() {
33620        let dir = PathBuf::from("/tmp/clones");
33621        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33622        let name = dest.file_name().unwrap().to_string_lossy();
33623        assert!(!name.is_empty());
33624        assert!(
33625            name.chars()
33626                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33627            "clone dest must only contain safe chars, got: {name}"
33628        );
33629    }
33630
33631    #[test]
33632    fn git_clone_dest_is_inside_clones_dir() {
33633        let dir = PathBuf::from("/tmp/clones");
33634        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33635        assert!(
33636            dest.starts_with(&dir),
33637            "clone dest must be inside clones_dir"
33638        );
33639    }
33640
33641    #[test]
33642    fn git_clone_dest_truncates_to_80_chars_max() {
33643        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33644        let dir = PathBuf::from("/tmp/clones");
33645        let dest = git_clone_dest(&long_url, &dir);
33646        let name = dest.file_name().unwrap().to_string_lossy();
33647        assert!(
33648            name.len() <= 80,
33649            "clone dest name must be at most 80 chars, got {} chars: {name}",
33650            name.len()
33651        );
33652    }
33653
33654    #[test]
33655    fn git_clone_dest_special_chars_replaced_with_underscore() {
33656        let dir = PathBuf::from("/tmp/clones");
33657        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33658        let name = dest.file_name().unwrap().to_string_lossy();
33659        assert!(
33660            !name.contains('@') && !name.contains(':') && !name.contains('/'),
33661            "special chars must be replaced in clone dest, got: {name}"
33662        );
33663    }
33664
33665    #[test]
33666    fn git_clone_dest_different_urls_differ() {
33667        let dir = PathBuf::from("/tmp/clones");
33668        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33669        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33670        assert_ne!(
33671            a, b,
33672            "different repos must produce different clone dest names"
33673        );
33674    }
33675
33676    #[test]
33677    fn git_clone_dest_same_url_same_result() {
33678        let dir = PathBuf::from("/tmp/clones");
33679        let url = "https://github.com/owner/repo.git";
33680        assert_eq!(
33681            git_clone_dest(url, &dir),
33682            git_clone_dest(url, &dir),
33683            "same URL must always give same clone dest"
33684        );
33685    }
33686
33687    // ── fmt_delta ─────────────────────────────────────────────────────────────
33688
33689    #[test]
33690    fn fmt_delta_positive_has_plus_prefix() {
33691        assert_eq!(fmt_delta(5), "+5");
33692    }
33693
33694    #[test]
33695    fn fmt_delta_negative_no_plus_prefix() {
33696        assert_eq!(fmt_delta(-3), "-3");
33697    }
33698
33699    #[test]
33700    fn fmt_delta_zero() {
33701        assert_eq!(fmt_delta(0), "0");
33702    }
33703
33704    // ── delta_class ───────────────────────────────────────────────────────────
33705
33706    #[test]
33707    fn delta_class_positive_is_pos() {
33708        assert_eq!(delta_class(1), "pos");
33709    }
33710
33711    #[test]
33712    fn delta_class_negative_is_neg() {
33713        assert_eq!(delta_class(-1), "neg");
33714    }
33715
33716    #[test]
33717    fn delta_class_zero_is_zero_class() {
33718        assert_eq!(delta_class(0), "zero");
33719    }
33720
33721    // ── fmt_pct ───────────────────────────────────────────────────────────────
33722
33723    #[test]
33724    fn fmt_pct_zero_baseline_returns_em_dash() {
33725        assert_eq!(fmt_pct(100, 0), "\u{2014}");
33726    }
33727
33728    #[test]
33729    fn fmt_pct_positive_delta_has_plus_sign() {
33730        let result = fmt_pct(10, 100);
33731        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33732    }
33733
33734    #[test]
33735    fn fmt_pct_negative_delta_no_plus_sign() {
33736        let result = fmt_pct(-10, 100);
33737        assert!(!result.starts_with('+'), "unexpected + in: {result}");
33738        assert!(result.contains('%'));
33739    }
33740
33741    #[test]
33742    fn fmt_pct_near_zero_returns_pm_zero() {
33743        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33744    }
33745
33746    // ── summary_delta ─────────────────────────────────────────────────────────
33747
33748    #[test]
33749    fn summary_delta_no_prev_returns_dash_na() {
33750        let (display, class) = summary_delta(10, None);
33751        assert_eq!(display, "\u{2014}");
33752        assert_eq!(class, "na");
33753    }
33754
33755    #[test]
33756    fn summary_delta_increase_is_positive() {
33757        let (display, class) = summary_delta(15, Some(10));
33758        assert_eq!(display, "+5");
33759        assert_eq!(class, "pos");
33760    }
33761
33762    #[test]
33763    fn summary_delta_decrease_is_negative() {
33764        let (display, class) = summary_delta(5, Some(10));
33765        assert_eq!(display, "-5");
33766        assert_eq!(class, "neg");
33767    }
33768
33769    // ── nth_weekday_of_month ──────────────────────────────────────────────────
33770
33771    #[test]
33772    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33773        use chrono::Datelike;
33774        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33775        assert_eq!(d.year(), 2024);
33776        assert_eq!(d.month(), 1);
33777        assert_eq!(d.weekday(), chrono::Weekday::Mon);
33778        assert!(d.day() <= 7);
33779    }
33780
33781    #[test]
33782    fn nth_weekday_second_sunday_march_2024_is_10th() {
33783        use chrono::Datelike;
33784        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33785        assert_eq!(d.weekday(), chrono::Weekday::Sun);
33786        assert_eq!(d.month(), 3);
33787        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33788    }
33789
33790    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
33791
33792    #[test]
33793    fn is_pacific_dst_july_is_true() {
33794        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33795        assert!(is_pacific_dst(dt), "July must be PDT");
33796    }
33797
33798    #[test]
33799    fn is_pacific_dst_january_is_false() {
33800        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33801        assert!(!is_pacific_dst(dt), "January must be PST");
33802    }
33803
33804    #[test]
33805    fn fmt_la_time_summer_shows_pdt() {
33806        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33807        let result = fmt_la_time(dt);
33808        assert!(
33809            result.ends_with("PDT"),
33810            "summer must use PDT, got: {result}"
33811        );
33812    }
33813
33814    #[test]
33815    fn fmt_la_time_winter_shows_pst() {
33816        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33817        let result = fmt_la_time(dt);
33818        assert!(
33819            result.ends_with("PST"),
33820            "winter must use PST, got: {result}"
33821        );
33822    }
33823
33824    #[test]
33825    fn fmt_la_time_meta_summer_shows_pdt() {
33826        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33827        let result = fmt_la_time_meta(dt);
33828        assert!(
33829            result.ends_with("PDT"),
33830            "meta summer must use PDT, got: {result}"
33831        );
33832    }
33833
33834    #[test]
33835    fn fmt_la_time_meta_winter_shows_pst() {
33836        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33837        let result = fmt_la_time_meta(dt);
33838        assert!(
33839            result.ends_with("PST"),
33840            "meta winter must use PST, got: {result}"
33841        );
33842    }
33843
33844    // ── fmt_git_date ──────────────────────────────────────────────────────────
33845
33846    #[test]
33847    fn fmt_git_date_valid_iso_returns_some() {
33848        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33849    }
33850
33851    #[test]
33852    fn fmt_git_date_invalid_returns_none() {
33853        assert!(fmt_git_date("not-a-date").is_none());
33854    }
33855
33856    // ── format_number ─────────────────────────────────────────────────────────
33857
33858    #[test]
33859    fn format_number_zero() {
33860        assert_eq!(format_number(0), "0");
33861    }
33862
33863    #[test]
33864    fn format_number_three_digits_no_comma() {
33865        assert_eq!(format_number(999), "999");
33866    }
33867
33868    #[test]
33869    fn format_number_four_digits_has_comma() {
33870        assert_eq!(format_number(1000), "1,000");
33871    }
33872
33873    #[test]
33874    fn format_number_seven_digits_two_commas() {
33875        assert_eq!(format_number(1_234_567), "1,234,567");
33876    }
33877
33878    #[test]
33879    fn format_number_one_million() {
33880        assert_eq!(format_number(1_000_000), "1,000,000");
33881    }
33882
33883    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
33884
33885    #[test]
33886    fn badge_text_px_empty_is_zero() {
33887        assert_eq!(badge_text_px(""), 0);
33888    }
33889
33890    #[test]
33891    fn badge_text_px_narrow_chars_smaller_than_normal() {
33892        assert!(
33893            badge_text_px("if") < badge_text_px("ab"),
33894            "'if' must be narrower than 'ab'"
33895        );
33896    }
33897
33898    #[test]
33899    fn badge_text_px_m_is_wider_than_a() {
33900        assert!(
33901            badge_text_px("m") > badge_text_px("a"),
33902            "'m' must be wider than 'a'"
33903        );
33904    }
33905
33906    #[test]
33907    fn render_badge_svg_contains_label_and_value() {
33908        let svg = render_badge_svg("coverage", "95%", "#4c1");
33909        assert!(svg.contains("coverage") && svg.contains("95%"));
33910    }
33911
33912    #[test]
33913    fn render_badge_svg_contains_color() {
33914        let svg = render_badge_svg("sloc", "12K", "#e05d44");
33915        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33916    }
33917
33918    #[test]
33919    fn render_badge_svg_escapes_ampersand_in_label() {
33920        let svg = render_badge_svg("test&label", "ok", "#4c1");
33921        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33922    }
33923
33924    // ── build_pdf_filename ────────────────────────────────────────────────────
33925
33926    #[test]
33927    fn build_pdf_filename_slugifies_title() {
33928        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33929        assert!(
33930            name.starts_with("my_project_report_")
33931                && std::path::Path::new(&name)
33932                    .extension()
33933                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33934        );
33935    }
33936
33937    #[test]
33938    fn build_pdf_filename_uses_last_run_id_segment() {
33939        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33940        assert!(name.contains("ABCD"), "must use last segment of run_id");
33941    }
33942
33943    #[test]
33944    fn build_pdf_filename_empty_title_uses_report_prefix() {
33945        let name = build_pdf_filename("", "abc-def-9999");
33946        assert!(
33947            name.starts_with("report_")
33948                && std::path::Path::new(&name)
33949                    .extension()
33950                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33951        );
33952    }
33953
33954    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33955
33956    #[test]
33957    fn swap_chart_js_replaces_inline_block() {
33958        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33959        let result = swap_inline_chart_js_for_static(html.to_string());
33960        assert!(result.contains(r#"src="/static/chart-report.js""#));
33961        assert!(!result.contains("inline source"));
33962    }
33963
33964    #[test]
33965    fn swap_chart_js_no_head_returns_unchanged() {
33966        let html = "<body>no head here</body>";
33967        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33968    }
33969
33970    #[test]
33971    fn swap_chart_js_no_script_in_head_unchanged() {
33972        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33973        let result = swap_inline_chart_js_for_static(html.to_string());
33974        assert!(!result.contains("chart-report.js"));
33975    }
33976
33977    // ── patch_html_nonce ──────────────────────────────────────────────────────
33978
33979    #[test]
33980    fn patch_html_nonce_replaces_old_nonce() {
33981        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33982        let result = patch_html_nonce(html, "new-nonce-456");
33983        assert!(result.contains(r#"nonce="new-nonce-456""#));
33984        assert!(!result.contains("old-nonce-123"));
33985    }
33986
33987    #[test]
33988    fn patch_html_nonce_injects_into_bare_style() {
33989        let html = "<style>body{color:red;}</style>";
33990        let result = patch_html_nonce(html, "fresh-nonce");
33991        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33992    }
33993
33994    #[test]
33995    fn patch_html_nonce_injects_into_bare_script() {
33996        let html = "<script>console.log(1);</script>";
33997        let result = patch_html_nonce(html, "abc");
33998        assert!(result.contains(r#"<script nonce="abc">"#));
33999    }
34000
34001    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
34002
34003    #[test]
34004    fn is_html_report_file_result_html_matches() {
34005        let dir = tempfile::tempdir().unwrap();
34006        let path = dir.path().join("result_20240101.html");
34007        std::fs::write(&path, b"<html></html>").unwrap();
34008        assert!(is_html_report_file(&path));
34009    }
34010
34011    #[test]
34012    fn is_html_report_file_report_html_matches() {
34013        let dir = tempfile::tempdir().unwrap();
34014        let path = dir.path().join("report_abc.html");
34015        std::fs::write(&path, b"<html></html>").unwrap();
34016        assert!(is_html_report_file(&path));
34017    }
34018
34019    #[test]
34020    fn is_html_report_file_index_html_does_not_match() {
34021        let dir = tempfile::tempdir().unwrap();
34022        let path = dir.path().join("index.html");
34023        std::fs::write(&path, b"<html></html>").unwrap();
34024        assert!(!is_html_report_file(&path));
34025    }
34026
34027    #[test]
34028    fn is_html_report_file_nonexistent_returns_false() {
34029        assert!(!is_html_report_file(Path::new(
34030            "/nonexistent/result_xyz.html"
34031        )));
34032    }
34033
34034    #[test]
34035    fn find_html_report_in_dir_finds_result_html() {
34036        let dir = tempfile::tempdir().unwrap();
34037        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34038        assert!(find_html_report_in_dir(dir.path()).is_some());
34039    }
34040
34041    #[test]
34042    fn find_html_report_in_dir_empty_returns_none() {
34043        let dir = tempfile::tempdir().unwrap();
34044        assert!(find_html_report_in_dir(dir.path()).is_none());
34045    }
34046
34047    #[test]
34048    fn find_html_report_in_tree_finds_in_subdir() {
34049        let dir = tempfile::tempdir().unwrap();
34050        let subdir = dir.path().join("run-001");
34051        std::fs::create_dir_all(&subdir).unwrap();
34052        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34053        assert!(find_html_report_in_tree(dir.path()).is_some());
34054    }
34055
34056    // ── derive_project_label ──────────────────────────────────────────────────
34057
34058    #[test]
34059    fn derive_project_label_with_git_repo_and_ref() {
34060        let label = derive_project_label(
34061            Some("https://github.com/owner/my-repo.git"),
34062            Some("main"),
34063            "/fallback/path",
34064        );
34065        assert!(!label.is_empty(), "label must not be empty");
34066        assert!(
34067            label.contains("my") || label.contains("repo"),
34068            "got: {label}"
34069        );
34070    }
34071
34072    #[test]
34073    fn derive_project_label_fallback_to_path() {
34074        let label = derive_project_label(None, None, "/path/to/myproject");
34075        assert_eq!(label, "myproject");
34076    }
34077
34078    #[test]
34079    fn derive_project_label_empty_git_fields_use_path() {
34080        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34081        assert_eq!(label, "cool-app");
34082    }
34083
34084    // ── derive_file_stem ──────────────────────────────────────────────────────
34085
34086    #[test]
34087    fn derive_file_stem_with_commit_appends_sha() {
34088        assert_eq!(
34089            derive_file_stem("myproject", Some("a1b2c3")),
34090            "myproject_a1b2c3"
34091        );
34092    }
34093
34094    #[test]
34095    fn derive_file_stem_without_commit_returns_label() {
34096        assert_eq!(derive_file_stem("myproject", None), "myproject");
34097    }
34098
34099    #[test]
34100    fn derive_file_stem_empty_commit_returns_label() {
34101        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34102    }
34103
34104    // ── split_patterns ────────────────────────────────────────────────────────
34105
34106    #[test]
34107    fn split_patterns_none_is_empty() {
34108        assert!(split_patterns(None).is_empty());
34109    }
34110
34111    #[test]
34112    fn split_patterns_empty_string_is_empty() {
34113        assert!(split_patterns(Some("")).is_empty());
34114    }
34115
34116    #[test]
34117    fn split_patterns_comma_separated() {
34118        assert_eq!(
34119            split_patterns(Some("foo,bar,baz")),
34120            vec!["foo", "bar", "baz"]
34121        );
34122    }
34123
34124    #[test]
34125    fn split_patterns_newline_separated() {
34126        assert_eq!(
34127            split_patterns(Some("foo\nbar\nbaz")),
34128            vec!["foo", "bar", "baz"]
34129        );
34130    }
34131
34132    #[test]
34133    fn split_patterns_trims_whitespace() {
34134        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
34135    }
34136
34137    // ── make_git_label ────────────────────────────────────────────────────────
34138
34139    #[test]
34140    fn make_git_label_empty_repo_empty_result() {
34141        assert_eq!(make_git_label("", "main"), "");
34142    }
34143
34144    #[test]
34145    fn make_git_label_empty_ref_empty_result() {
34146        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34147    }
34148
34149    #[test]
34150    fn make_git_label_basic_format() {
34151        assert_eq!(
34152            make_git_label("https://github.com/owner/my-repo.git", "main"),
34153            "my-repo_at_main_sloc"
34154        );
34155    }
34156
34157    #[test]
34158    fn make_git_label_slash_in_ref_replaced() {
34159        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34160        assert!(
34161            !label.contains('/'),
34162            "slash in ref must be replaced: {label}"
34163        );
34164    }
34165
34166    // ── format_dir_size ───────────────────────────────────────────────────────
34167
34168    #[test]
34169    fn format_dir_size_bytes() {
34170        assert_eq!(format_dir_size(500), "500 B");
34171    }
34172
34173    #[test]
34174    fn format_dir_size_kilobytes() {
34175        assert_eq!(format_dir_size(2048), "2 KB");
34176    }
34177
34178    #[test]
34179    fn format_dir_size_megabytes() {
34180        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34181    }
34182
34183    #[test]
34184    fn format_dir_size_gigabytes() {
34185        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34186    }
34187
34188    #[test]
34189    fn format_dir_size_zero() {
34190        assert_eq!(format_dir_size(0), "0 B");
34191    }
34192
34193    // ── civil_from_days ───────────────────────────────────────────────────────
34194
34195    #[test]
34196    fn civil_from_days_epoch() {
34197        assert_eq!(civil_from_days(0), (1970, 1, 1));
34198    }
34199
34200    #[test]
34201    fn civil_from_days_one_year_later() {
34202        assert_eq!(civil_from_days(365), (1971, 1, 1));
34203    }
34204
34205    #[test]
34206    fn civil_from_days_31_days_is_feb_1_1970() {
34207        assert_eq!(civil_from_days(31), (1970, 2, 1));
34208    }
34209
34210    // ── format_system_time ────────────────────────────────────────────────────
34211
34212    #[test]
34213    fn format_system_time_unix_epoch_formats_correctly() {
34214        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34215    }
34216
34217    #[test]
34218    fn format_system_time_31_days_after_epoch() {
34219        let t = UNIX_EPOCH + Duration::from_hours(744);
34220        assert_eq!(format_system_time(t), "1970-02-01 00:00");
34221    }
34222
34223    #[test]
34224    fn format_system_time_before_epoch_returns_dash() {
34225        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34226            assert_eq!(format_system_time(before), "-");
34227        }
34228    }
34229
34230    // ── detect_language_name ──────────────────────────────────────────────────
34231
34232    #[test]
34233    fn detect_language_name_dot_c() {
34234        assert_eq!(detect_language_name("main.c"), Some("C"));
34235    }
34236
34237    #[test]
34238    fn detect_language_name_dot_h() {
34239        assert_eq!(detect_language_name("defs.h"), Some("C"));
34240    }
34241
34242    #[test]
34243    fn detect_language_name_dot_cpp() {
34244        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34245    }
34246
34247    #[test]
34248    fn detect_language_name_dot_py() {
34249        assert_eq!(detect_language_name("script.py"), Some("Python"));
34250    }
34251
34252    #[test]
34253    fn detect_language_name_dot_ps1() {
34254        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34255    }
34256
34257    #[test]
34258    fn detect_language_name_dot_cs() {
34259        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34260    }
34261
34262    #[test]
34263    fn detect_language_name_dot_sh() {
34264        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34265    }
34266
34267    #[test]
34268    fn detect_language_name_unknown_txt() {
34269        assert_eq!(detect_language_name("notes.txt"), None);
34270    }
34271
34272    // ── language_icon_file ────────────────────────────────────────────────────
34273
34274    #[test]
34275    fn language_icon_file_c() {
34276        assert_eq!(language_icon_file("C"), Some("c.png"));
34277    }
34278
34279    #[test]
34280    fn language_icon_file_python() {
34281        assert_eq!(language_icon_file("Python"), Some("python.png"));
34282    }
34283
34284    #[test]
34285    fn language_icon_file_dockerfile() {
34286        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34287    }
34288
34289    #[test]
34290    fn language_icon_file_rust_is_none() {
34291        assert!(language_icon_file("Rust").is_none());
34292    }
34293
34294    #[test]
34295    fn language_icon_file_unknown_is_none() {
34296        assert!(language_icon_file("Fortran").is_none());
34297    }
34298
34299    // ── language_inline_svg ───────────────────────────────────────────────────
34300
34301    #[test]
34302    fn language_inline_svg_rust_is_svg() {
34303        let svg = language_inline_svg("Rust").unwrap();
34304        assert!(svg.starts_with("<svg"));
34305    }
34306
34307    #[test]
34308    fn language_inline_svg_typescript_is_some() {
34309        assert!(language_inline_svg("TypeScript").is_some());
34310    }
34311
34312    #[test]
34313    fn language_inline_svg_unknown_is_none() {
34314        assert!(language_inline_svg("Fortran").is_none());
34315    }
34316
34317    // ── classify_preview_file ─────────────────────────────────────────────────
34318
34319    #[test]
34320    fn classify_preview_file_c_supported() {
34321        assert!(matches!(
34322            classify_preview_file("main.c"),
34323            PreviewKind::Supported
34324        ));
34325    }
34326
34327    #[test]
34328    fn classify_preview_file_python_supported() {
34329        assert!(matches!(
34330            classify_preview_file("script.py"),
34331            PreviewKind::Supported
34332        ));
34333    }
34334
34335    #[test]
34336    fn classify_preview_file_png_skipped() {
34337        assert!(matches!(
34338            classify_preview_file("image.png"),
34339            PreviewKind::Skipped
34340        ));
34341    }
34342
34343    #[test]
34344    fn classify_preview_file_zip_skipped() {
34345        assert!(matches!(
34346            classify_preview_file("archive.zip"),
34347            PreviewKind::Skipped
34348        ));
34349    }
34350
34351    #[test]
34352    fn classify_preview_file_min_js_skipped() {
34353        assert!(matches!(
34354            classify_preview_file("bundle.min.js"),
34355            PreviewKind::Skipped
34356        ));
34357    }
34358
34359    #[test]
34360    fn classify_preview_file_rs_unsupported() {
34361        assert!(matches!(
34362            classify_preview_file("main.rs"),
34363            PreviewKind::Unsupported
34364        ));
34365    }
34366
34367    // ── preview_relative_path ─────────────────────────────────────────────────
34368
34369    #[test]
34370    fn preview_relative_path_strips_root() {
34371        let root = PathBuf::from("/project");
34372        let path = PathBuf::from("/project/src/main.c");
34373        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34374    }
34375
34376    #[test]
34377    fn preview_relative_path_unrooted_includes_filename() {
34378        let root = PathBuf::from("/other");
34379        let path = PathBuf::from("/project/src/main.c");
34380        let result = preview_relative_path(&root, &path);
34381        assert!(result.contains("main.c"));
34382    }
34383
34384    #[test]
34385    fn preview_relative_path_uses_forward_slashes() {
34386        let root = PathBuf::from("/project");
34387        let path = PathBuf::from("/project/a/b/c.py");
34388        assert!(!preview_relative_path(&root, &path).contains('\\'));
34389    }
34390
34391    // ── wildcard_match ────────────────────────────────────────────────────────
34392
34393    #[test]
34394    fn wildcard_match_exact_equal() {
34395        assert!(wildcard_match("foo", "foo"));
34396    }
34397
34398    #[test]
34399    fn wildcard_match_exact_mismatch() {
34400        assert!(!wildcard_match("foo", "bar"));
34401    }
34402
34403    #[test]
34404    fn wildcard_match_star_suffix() {
34405        assert!(wildcard_match("*.rs", "main.rs"));
34406    }
34407
34408    #[test]
34409    fn wildcard_match_star_middle_requires_suffix() {
34410        assert!(!wildcard_match("a*b", "ac"));
34411    }
34412
34413    #[test]
34414    fn wildcard_match_question_mark_single_char() {
34415        assert!(wildcard_match("f?o", "foo"));
34416    }
34417
34418    #[test]
34419    fn wildcard_match_double_star_nested() {
34420        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34421    }
34422
34423    #[test]
34424    fn wildcard_match_star_directory_entry() {
34425        assert!(wildcard_match("vendor/*", "vendor/crate"));
34426    }
34427
34428    #[test]
34429    fn wildcard_match_no_cross_prefix() {
34430        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34431    }
34432
34433    // ── should_skip_preview_directory ────────────────────────────────────────
34434
34435    #[test]
34436    fn should_skip_empty_relative_is_false() {
34437        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34438    }
34439
34440    #[test]
34441    fn should_skip_matching_pattern() {
34442        assert!(should_skip_preview_directory(
34443            "vendor",
34444            &["vendor".to_string()]
34445        ));
34446    }
34447
34448    #[test]
34449    fn should_skip_non_matching() {
34450        assert!(!should_skip_preview_directory(
34451            "src",
34452            &["vendor".to_string()]
34453        ));
34454    }
34455
34456    #[test]
34457    fn should_skip_wildcard_prefix() {
34458        assert!(should_skip_preview_directory(
34459            "target/debug",
34460            &["target*".to_string()]
34461        ));
34462    }
34463
34464    // ── should_include_preview_file ───────────────────────────────────────────
34465
34466    #[test]
34467    fn should_include_empty_relative_always_true() {
34468        assert!(should_include_preview_file("", &[], &[]));
34469    }
34470
34471    #[test]
34472    fn should_include_no_patterns_includes_all() {
34473        assert!(should_include_preview_file("src/main.c", &[], &[]));
34474    }
34475
34476    #[test]
34477    fn should_include_excluded_by_pattern() {
34478        assert!(!should_include_preview_file(
34479            "vendor/lib.c",
34480            &[],
34481            &["vendor/*".to_string()]
34482        ));
34483    }
34484
34485    #[test]
34486    fn should_include_include_pattern_filters() {
34487        assert!(!should_include_preview_file(
34488            "tests/test_foo.c",
34489            &["src/*".to_string()],
34490            &[]
34491        ));
34492    }
34493
34494    // ── escape_html ───────────────────────────────────────────────────────────
34495
34496    #[test]
34497    fn escape_html_ampersand() {
34498        assert_eq!(escape_html("a&b"), "a&amp;b");
34499    }
34500
34501    #[test]
34502    fn escape_html_angle_brackets() {
34503        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
34504    }
34505
34506    #[test]
34507    fn escape_html_double_quote() {
34508        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
34509    }
34510
34511    #[test]
34512    fn escape_html_single_quote() {
34513        assert_eq!(escape_html("it's"), "it&#39;s");
34514    }
34515
34516    #[test]
34517    fn escape_html_plain_text_unchanged() {
34518        assert_eq!(escape_html("hello world"), "hello world");
34519    }
34520
34521    // ── sum_added / removed / unmodified code lines ───────────────────────────
34522
34523    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34524        sloc_core::ScanComparison {
34525            summary: sloc_core::SummaryDelta {
34526                baseline_run_id: "base".to_string(),
34527                current_run_id: "curr".to_string(),
34528                baseline_timestamp: chrono::Utc::now(),
34529                current_timestamp: chrono::Utc::now(),
34530                baseline_files: 4,
34531                current_files: 4,
34532                files_analyzed_delta: 0,
34533                baseline_code: 330,
34534                current_code: 400,
34535                code_lines_delta: 70,
34536                baseline_comments: 0,
34537                current_comments: 0,
34538                comment_lines_delta: 0,
34539                blank_lines_delta: 0,
34540                total_lines_delta: 70,
34541                coverage_lines_hit_delta: None,
34542                coverage_line_pct_delta: None,
34543                baseline_coverage_line_pct: None,
34544                current_coverage_line_pct: None,
34545            },
34546            file_deltas: vec![
34547                sloc_core::FileDelta {
34548                    relative_path: "added.rs".to_string(),
34549                    language: Some("Rust".to_string()),
34550                    status: FileChangeStatus::Added,
34551                    baseline_code: 0,
34552                    current_code: 100,
34553                    code_delta: 100,
34554                    baseline_comment: 0,
34555                    current_comment: 0,
34556                    comment_delta: 0,
34557                    baseline_blank: 0,
34558                    current_blank: 0,
34559                    blank_delta: 0,
34560                    total_delta: 100,
34561                },
34562                sloc_core::FileDelta {
34563                    relative_path: "removed.rs".to_string(),
34564                    language: Some("Rust".to_string()),
34565                    status: FileChangeStatus::Removed,
34566                    baseline_code: 50,
34567                    current_code: 0,
34568                    code_delta: -50,
34569                    baseline_comment: 0,
34570                    current_comment: 0,
34571                    comment_delta: 0,
34572                    baseline_blank: 0,
34573                    current_blank: 0,
34574                    blank_delta: 0,
34575                    total_delta: -50,
34576                },
34577                sloc_core::FileDelta {
34578                    relative_path: "modified.rs".to_string(),
34579                    language: Some("Rust".to_string()),
34580                    status: FileChangeStatus::Modified,
34581                    baseline_code: 80,
34582                    current_code: 100,
34583                    code_delta: 20,
34584                    baseline_comment: 0,
34585                    current_comment: 0,
34586                    comment_delta: 0,
34587                    baseline_blank: 0,
34588                    current_blank: 0,
34589                    blank_delta: 0,
34590                    total_delta: 20,
34591                },
34592                sloc_core::FileDelta {
34593                    relative_path: "unchanged.rs".to_string(),
34594                    language: Some("Rust".to_string()),
34595                    status: FileChangeStatus::Unchanged,
34596                    baseline_code: 200,
34597                    current_code: 200,
34598                    code_delta: 0,
34599                    baseline_comment: 0,
34600                    current_comment: 0,
34601                    comment_delta: 0,
34602                    baseline_blank: 0,
34603                    current_blank: 0,
34604                    blank_delta: 0,
34605                    total_delta: 0,
34606                },
34607            ],
34608            files_added: 1,
34609            files_removed: 1,
34610            files_modified: 1,
34611            files_unchanged: 1,
34612            files_total: 4,
34613        }
34614    }
34615
34616    #[test]
34617    fn sum_added_counts_added_and_positive_modified() {
34618        let cmp = make_mixed_scan_comparison();
34619        assert_eq!(sum_added_code_lines(&cmp), 120);
34620    }
34621
34622    #[test]
34623    fn sum_removed_counts_removed_baseline() {
34624        let cmp = make_mixed_scan_comparison();
34625        assert_eq!(sum_removed_code_lines(&cmp), 50);
34626    }
34627
34628    #[test]
34629    fn sum_unmodified_counts_unchanged_files() {
34630        let cmp = make_mixed_scan_comparison();
34631        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34632    }
34633
34634    // ── detect_coverage_tool ──────────────────────────────────────────────────
34635
34636    #[test]
34637    fn detect_coverage_tool_rust_project() {
34638        let dir = tempfile::tempdir().unwrap();
34639        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34640        let (tool, cmd) = detect_coverage_tool(dir.path());
34641        assert_eq!(tool, Some("cargo-llvm-cov"));
34642        assert!(cmd.is_some());
34643    }
34644
34645    #[test]
34646    fn detect_coverage_tool_java_gradle() {
34647        let dir = tempfile::tempdir().unwrap();
34648        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34649        let (tool, _) = detect_coverage_tool(dir.path());
34650        assert_eq!(tool, Some("jacoco"));
34651    }
34652
34653    #[test]
34654    fn detect_coverage_tool_python_pyproject() {
34655        let dir = tempfile::tempdir().unwrap();
34656        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34657        let (tool, _) = detect_coverage_tool(dir.path());
34658        assert_eq!(tool, Some("pytest-cov"));
34659    }
34660
34661    #[test]
34662    fn detect_coverage_tool_unknown_project() {
34663        let dir = tempfile::tempdir().unwrap();
34664        let (tool, cmd) = detect_coverage_tool(dir.path());
34665        assert!(tool.is_none() && cmd.is_none());
34666    }
34667
34668    // ── sanitize_path_str / display_path ─────────────────────────────────────
34669
34670    #[test]
34671    fn sanitize_path_str_unc_drive_stripped() {
34672        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34673    }
34674
34675    #[test]
34676    fn sanitize_path_str_unc_network_stripped() {
34677        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34678    }
34679
34680    #[test]
34681    fn sanitize_path_str_plain_path_unchanged() {
34682        assert_eq!(
34683            sanitize_path_str("/home/user/project"),
34684            "/home/user/project"
34685        );
34686    }
34687
34688    #[test]
34689    fn display_path_plain_linux_unchanged() {
34690        assert_eq!(
34691            display_path(Path::new("/home/user/project")),
34692            "/home/user/project"
34693        );
34694    }
34695
34696    #[test]
34697    fn display_path_unc_drive_stripped() {
34698        let result = display_path(Path::new(r"\\?\C:\Users\user"));
34699        assert_eq!(result, r"C:\Users\user");
34700    }
34701
34702    #[test]
34703    fn display_path_unc_network_stripped() {
34704        let result = display_path(Path::new(r"\\?\UNC\server\share"));
34705        assert_eq!(result, r"\\server\share");
34706    }
34707}
34708
34709#[cfg(test)]
34710mod coverage_boost_unit_tests {
34711    use super::*;
34712    use std::path::{Path, PathBuf};
34713
34714    // Both scenarios live in one test (sequential, under a Tokio runtime) because
34715    // load_runtime_security_config spawns a pruning task and mutates process-global
34716    // env vars — parallel sub-tests would race on both.
34717    #[tokio::test]
34718    async fn runtime_security_config_scenarios() {
34719        // FIXME: Audit that the environment access only happens in single-threaded code.
34720        unsafe { std::env::remove_var("SLOC_API_KEYS") };
34721        // FIXME: Audit that the environment access only happens in single-threaded code.
34722        unsafe { std::env::remove_var("SLOC_API_KEY") };
34723        // FIXME: Audit that the environment access only happens in single-threaded code.
34724        unsafe { std::env::remove_var("SLOC_TLS_CERT") };
34725        // FIXME: Audit that the environment access only happens in single-threaded code.
34726        unsafe { std::env::remove_var("SLOC_TLS_KEY") };
34727        // FIXME: Audit that the environment access only happens in single-threaded code.
34728        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34729        // FIXME: Audit that the environment access only happens in single-threaded code.
34730        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34731        let cfg = load_runtime_security_config(false);
34732        assert!(cfg.api_keys.is_empty());
34733        assert!(!cfg.tls_enabled);
34734        assert!(!cfg.trust_proxy);
34735
34736        // FIXME: Audit that the environment access only happens in single-threaded code.
34737        unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
34738        // FIXME: Audit that the environment access only happens in single-threaded code.
34739        unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
34740        // FIXME: Audit that the environment access only happens in single-threaded code.
34741        unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
34742        // FIXME: Audit that the environment access only happens in single-threaded code.
34743        unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
34744        // FIXME: Audit that the environment access only happens in single-threaded code.
34745        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
34746        // FIXME: Audit that the environment access only happens in single-threaded code.
34747        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
34748        let cfg = load_runtime_security_config(true);
34749        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34750        assert!(cfg.trust_proxy);
34751        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34752        // FIXME: Audit that the environment access only happens in single-threaded code.
34753        unsafe { std::env::remove_var("SLOC_API_KEYS") };
34754        // FIXME: Audit that the environment access only happens in single-threaded code.
34755        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34756        // FIXME: Audit that the environment access only happens in single-threaded code.
34757        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34758        // FIXME: Audit that the environment access only happens in single-threaded code.
34759        unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
34760        // FIXME: Audit that the environment access only happens in single-threaded code.
34761        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
34762        // FIXME: Audit that the environment access only happens in single-threaded code.
34763        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
34764    }
34765
34766    #[test]
34767    fn cors_layer_builds_both_modes() {
34768        let _ = build_cors_layer(true);
34769        let _ = build_cors_layer(false);
34770    }
34771
34772    #[test]
34773    fn primary_lan_ip_callable() {
34774        // May be Some or None depending on the host; both are valid.
34775        let _ = primary_lan_ip();
34776    }
34777
34778    #[test]
34779    fn safe_redirect_allows_relative_rejects_absolute() {
34780        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34781        assert_eq!(safe_redirect("https://evil.example/x"), "/");
34782        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34783        assert_eq!(default_redirect(), "/view-reports");
34784    }
34785
34786    #[test]
34787    fn tarball_size_caps_env_override() {
34788        // FIXME: Audit that the environment access only happens in single-threaded code.
34789        unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
34790        // FIXME: Audit that the environment access only happens in single-threaded code.
34791        unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
34792        let (c, d) = parse_tarball_size_caps();
34793        assert_eq!(c, 1024 * 1024);
34794        assert_eq!(d, 2 * 1024 * 1024);
34795        // FIXME: Audit that the environment access only happens in single-threaded code.
34796        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
34797        // FIXME: Audit that the environment access only happens in single-threaded code.
34798        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
34799        let (c2, _) = parse_tarball_size_caps();
34800        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34801    }
34802
34803    #[test]
34804    fn upload_path_helpers() {
34805        let base = upload_base_dir();
34806        let staged = upload_staging_path("abc123");
34807        assert!(staged.starts_with(&base));
34808        assert!(
34809            is_upload_tmp_path(&staged),
34810            "staging path is an upload tmp path"
34811        );
34812        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34813    }
34814
34815    #[test]
34816    fn git_clones_dir_env_override() {
34817        // FIXME: Audit that the environment access only happens in single-threaded code.
34818        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
34819        let def = resolve_git_clones_dir(Path::new("/out"));
34820        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34821        // FIXME: Audit that the environment access only happens in single-threaded code.
34822        unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
34823        assert_eq!(
34824            resolve_git_clones_dir(Path::new("/out")),
34825            PathBuf::from("/custom/clones")
34826        );
34827        // FIXME: Audit that the environment access only happens in single-threaded code.
34828        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
34829    }
34830
34831    #[test]
34832    fn html_report_file_detection() {
34833        let dir = std::env::temp_dir().join("sloc_html_detect");
34834        let _ = std::fs::create_dir_all(&dir);
34835        let good = dir.join("report_x.html");
34836        std::fs::write(&good, "<html></html>").unwrap();
34837        let bad = dir.join("notes.txt");
34838        std::fs::write(&bad, "x").unwrap();
34839        assert!(is_html_report_file(&good));
34840        assert!(!is_html_report_file(&bad));
34841        assert!(find_html_report_in_dir(&dir).is_some());
34842        let _ = std::fs::remove_dir_all(&dir);
34843    }
34844
34845    #[test]
34846    fn multi_delta_class_and_format() {
34847        assert_eq!(multi_delta_class(5), "pos");
34848        assert_eq!(multi_delta_class(-5), "neg");
34849        assert_eq!(multi_delta_class(0), "zero");
34850        assert_eq!(multi_fmt_delta(3), "+3");
34851        assert_eq!(multi_fmt_delta(-3), "-3");
34852        assert_eq!(multi_fmt_delta(0), "0");
34853    }
34854
34855    #[test]
34856    fn git_clone_dest_sanitizes() {
34857        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34858        assert!(dest.starts_with("/clones"));
34859        let name = dest.file_name().unwrap().to_str().unwrap();
34860        assert!(
34861            name.chars()
34862                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
34863        );
34864    }
34865}
34866
34867#[cfg(test)]
34868mod tests_private {
34869    use super::*;
34870    use std::io::Read;
34871
34872    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
34873
34874    #[test]
34875    fn local_mode_never_refuses_start() {
34876        // Desktop / local mode is open by design regardless of key presence.
34877        assert!(!refuse_unauthenticated_server(false, false));
34878        assert!(!refuse_unauthenticated_server(false, true));
34879    }
34880
34881    #[test]
34882    fn server_mode_with_key_is_allowed() {
34883        assert!(!refuse_unauthenticated_server(true, true));
34884    }
34885
34886    // Env-mutating assertions live in one test so they run sequentially: the
34887    // process-global env var would otherwise race across parallel test threads.
34888    #[test]
34889    fn server_mode_auth_gate_respects_optin() {
34890        // FIXME: Audit that the environment access only happens in single-threaded code.
34891        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
34892        assert!(
34893            refuse_unauthenticated_server(true, false),
34894            "server mode + no key must fail closed by default"
34895        );
34896        // FIXME: Audit that the environment access only happens in single-threaded code.
34897        unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
34898        assert!(
34899            !refuse_unauthenticated_server(true, false),
34900            "explicit opt-in must allow the unauthenticated server"
34901        );
34902        // FIXME: Audit that the environment access only happens in single-threaded code.
34903        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
34904    }
34905
34906    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
34907
34908    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
34909    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
34910    /// genuinely malicious archive, which is where the zip-slip guard must hold.
34911    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34912        let mut h = [0u8; 512];
34913        let nb = name.as_bytes();
34914        h[..nb.len()].copy_from_slice(nb);
34915        h[100..108].copy_from_slice(b"0000644\0");
34916        h[108..116].copy_from_slice(b"0000000\0");
34917        h[116..124].copy_from_slice(b"0000000\0");
34918        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34919        h[136..148].copy_from_slice(b"00000000000\0");
34920        h[156] = b'0'; // typeflag: regular file
34921        h[257..263].copy_from_slice(b"ustar\0");
34922        h[263..265].copy_from_slice(b"00");
34923        for b in &mut h[148..156] {
34924            *b = b' ';
34925        }
34926        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34927        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34928
34929        let mut out = h.to_vec();
34930        out.extend_from_slice(data);
34931        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
34932        out.resize(out.len() + 1024, 0); // two trailing zero blocks
34933        out
34934    }
34935
34936    /// A malicious tar whose entry path escapes the destination via `..` must not
34937    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
34938    /// zip-slip guard as a regression test.
34939    #[tokio::test]
34940    async fn tarball_extraction_blocks_zip_slip() {
34941        use std::io::Write as _;
34942
34943        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34944        let staging = base.join("staging");
34945        let tar_gz = base.join("evil.tar.gz");
34946        std::fs::create_dir_all(&base).unwrap();
34947
34948        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
34949        {
34950            let f = std::fs::File::create(&tar_gz).unwrap();
34951            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34952            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34953                .unwrap();
34954            enc.finish().unwrap().flush().unwrap();
34955        }
34956
34957        // Extraction must not write the escaped file beside the staging directory.
34958        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34959
34960        let escaped = base.join("escaped.txt");
34961        assert!(
34962            !escaped.exists(),
34963            "zip-slip entry escaped staging to {}",
34964            escaped.display()
34965        );
34966
34967        let _ = std::fs::remove_dir_all(&base);
34968    }
34969
34970    #[test]
34971    fn size_limit_reader_zero_remaining_returns_error() {
34972        let data = b"hello world";
34973        let mut reader = SizeLimitReader {
34974            inner: &data[..],
34975            remaining: 0,
34976        };
34977        let mut buf = [0u8; 4];
34978        assert!(reader.read(&mut buf).is_err());
34979    }
34980
34981    #[test]
34982    fn size_limit_reader_counts_bytes() {
34983        let data = b"hello world";
34984        let mut reader = SizeLimitReader {
34985            inner: &data[..],
34986            remaining: 5,
34987        };
34988        let mut buf = [0u8; 4];
34989        let n = reader.read(&mut buf).unwrap();
34990        assert_eq!(n, 4);
34991        assert_eq!(reader.remaining, 1);
34992    }
34993
34994    #[test]
34995    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34996        let uuid = "12345678-1234-1234-1234-123456789012";
34997        let (id, path) = resolve_or_create_staging(Some(uuid));
34998        assert_eq!(id, uuid);
34999        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
35000    }
35001
35002    #[test]
35003    fn resolve_or_create_staging_with_none_creates_new() {
35004        let (id1, _) = resolve_or_create_staging(None);
35005        let (id2, _) = resolve_or_create_staging(None);
35006        assert_ne!(id1, id2);
35007    }
35008
35009    #[test]
35010    fn resolve_or_create_staging_with_path_separator_creates_new() {
35011        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
35012        let (id, _) = resolve_or_create_staging(Some("has/slash"));
35013        assert_ne!(id, "has/slash");
35014    }
35015
35016    #[test]
35017    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
35018        use std::net::IpAddr;
35019        use std::str::FromStr;
35020        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
35021        let ip = IpAddr::from_str("192.168.1.1").unwrap();
35022        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
35023    }
35024
35025    #[test]
35026    fn is_auth_locked_out_expired_entry_removed() {
35027        use std::net::IpAddr;
35028        use std::str::FromStr;
35029        let limiter = IpRateLimiter::new(
35030            Duration::from_mins(1),
35031            100,
35032            1, // 1 failure triggers lockout
35033            Duration::from_millis(1),
35034        );
35035        let ip = IpAddr::from_str("192.168.1.2").unwrap();
35036        limiter.record_auth_failure(ip);
35037        // Wait for the 1ms window to expire
35038        std::thread::sleep(Duration::from_millis(10));
35039        // Expired entry should be removed, returning false
35040        assert!(!limiter.is_auth_locked_out(ip));
35041    }
35042
35043    #[test]
35044    fn is_auth_locked_out_within_window_returns_true() {
35045        use std::net::IpAddr;
35046        use std::str::FromStr;
35047        let limiter = IpRateLimiter::new(
35048            Duration::from_mins(1),
35049            100,
35050            2, // 2 failures triggers lockout
35051            Duration::from_hours(1),
35052        );
35053        let ip = IpAddr::from_str("192.168.1.3").unwrap();
35054        limiter.record_auth_failure(ip);
35055        limiter.record_auth_failure(ip);
35056        assert!(limiter.is_auth_locked_out(ip));
35057    }
35058
35059    // ── output_folder_hint ───────────────────────────────────────────────────────
35060
35061    #[test]
35062    fn output_folder_hint_strips_json_subdir() {
35063        use std::path::Path;
35064        let path = Path::new("/output/scan1/json/result.json");
35065        let hint = output_folder_hint(path);
35066        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35067    }
35068
35069    #[test]
35070    fn output_folder_hint_strips_html_subdir() {
35071        use std::path::Path;
35072        let path = Path::new("/output/scan1/html/report.html");
35073        let hint = output_folder_hint(path);
35074        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35075    }
35076
35077    #[test]
35078    fn output_folder_hint_strips_pdf_subdir() {
35079        use std::path::Path;
35080        let path = Path::new("/output/scan1/pdf/report.pdf");
35081        let hint = output_folder_hint(path);
35082        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35083    }
35084
35085    #[test]
35086    fn output_folder_hint_strips_excel_subdir() {
35087        use std::path::Path;
35088        let path = Path::new("/output/scan1/excel/report.xlsx");
35089        let hint = output_folder_hint(path);
35090        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35091    }
35092
35093    #[test]
35094    fn output_folder_hint_flat_layout_returns_direct_parent() {
35095        use std::path::Path;
35096        let path = Path::new("/output/scan1/result.json");
35097        let hint = output_folder_hint(path);
35098        assert!(
35099            hint.ends_with("scan1"),
35100            "expected direct parent, got: {hint}"
35101        );
35102    }
35103
35104    #[test]
35105    fn output_folder_hint_other_subdir_name_not_stripped() {
35106        use std::path::Path;
35107        // "data" is not one of the named artifact subdirs — parent is kept as-is
35108        let path = Path::new("/output/scan1/data/result.json");
35109        let hint = output_folder_hint(path);
35110        assert!(
35111            hint.ends_with("data"),
35112            "non-artifact subdir must not be stripped, got: {hint}"
35113        );
35114    }
35115
35116    // ── find_file_by_ext ─────────────────────────────────────────────────────────
35117
35118    #[test]
35119    fn find_file_by_ext_finds_matching_file() {
35120        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35121        let _ = fs::create_dir_all(&dir);
35122        let f = dir.join("report.pdf");
35123        let _ = fs::write(&f, b"dummy");
35124        let result = find_file_by_ext(&dir, "pdf");
35125        assert!(result.is_some(), "expected to find report.pdf");
35126        let _ = fs::remove_dir_all(&dir);
35127    }
35128
35129    #[test]
35130    fn find_file_by_ext_returns_none_for_missing_ext() {
35131        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35132        let _ = fs::create_dir_all(&dir);
35133        let f = dir.join("report.json");
35134        let _ = fs::write(&f, b"{}");
35135        let result = find_file_by_ext(&dir, "pdf");
35136        assert!(result.is_none());
35137        let _ = fs::remove_dir_all(&dir);
35138    }
35139
35140    #[test]
35141    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35142        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35143        assert!(find_file_by_ext(dir, "json").is_none());
35144    }
35145
35146    // ── collect_result_json_candidates ───────────────────────────────────────────
35147
35148    #[test]
35149    fn collect_result_json_candidates_flat_root() {
35150        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35151        let _ = fs::create_dir_all(&root);
35152        let _ = fs::write(root.join("result.json"), b"{}");
35153        let candidates = collect_result_json_candidates(&root);
35154        assert!(!candidates.is_empty(), "should find result.json at root");
35155        let _ = fs::remove_dir_all(&root);
35156    }
35157
35158    #[test]
35159    fn collect_result_json_candidates_legacy_subdir() {
35160        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35161        let sub = root.join("scanA");
35162        let _ = fs::create_dir_all(&sub);
35163        let _ = fs::write(sub.join("result.json"), b"{}");
35164        let candidates = collect_result_json_candidates(&root);
35165        assert!(
35166            !candidates.is_empty(),
35167            "should find result.json in legacy subdir"
35168        );
35169        let _ = fs::remove_dir_all(&root);
35170    }
35171
35172    #[test]
35173    fn collect_result_json_candidates_structured_json_subdir() {
35174        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35175        let json_sub = root.join("scanB").join("json");
35176        let _ = fs::create_dir_all(&json_sub);
35177        let _ = fs::write(json_sub.join("result.json"), b"{}");
35178        let candidates = collect_result_json_candidates(&root);
35179        assert!(
35180            !candidates.is_empty(),
35181            "should find result.json inside <subdir>/json/"
35182        );
35183        let _ = fs::remove_dir_all(&root);
35184    }
35185
35186    #[test]
35187    fn collect_result_json_candidates_empty_dir() {
35188        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35189        let _ = fs::create_dir_all(&root);
35190        let candidates = collect_result_json_candidates(&root);
35191        assert!(candidates.is_empty());
35192        let _ = fs::remove_dir_all(&root);
35193    }
35194}