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(crate) mod auth;
27pub(crate) mod confluence;
28pub(crate) mod error;
29pub(crate) mod git_browser;
30pub(crate) mod git_webhook;
31pub(crate) mod integrations;
32
33use std::{
34    collections::{HashMap, VecDeque},
35    fmt::Write,
36    fs,
37    net::{IpAddr, SocketAddr},
38    path::{Path, PathBuf},
39    process::Stdio,
40    sync::{Arc, OnceLock},
41    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
42};
43
44use anyhow::{Context, Result};
45use askama::Template;
46use axum::{
47    body::Body,
48    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
49    http::{header, HeaderValue, Request, StatusCode},
50    middleware::{self, Next},
51    response::{Html, IntoResponse, Response},
52    routing::{get, post},
53    Json, Router,
54};
55use serde::{Deserialize, Serialize};
56use tokio::sync::Mutex;
57use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
58
59use sloc_config::{
60    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
61    MixedLinePolicy,
62};
63use sloc_git::ScheduleStore;
64
65#[derive(Clone)]
66pub(crate) struct CspNonce(pub(crate) String);
67
68static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
69static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
70
71use sloc_core::{
72    analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
73    CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
74    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
75};
76use sloc_report::{
77    render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
78    write_pdf_from_run, ReportDeltaContext,
79};
80const MAX_CONCURRENT_ANALYSES: usize = 4;
81
82/// Windows-only helpers that force the native file-picker dialog into the
83/// foreground instead of appearing minimised behind other windows.
84///
85/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
86/// foreground thread so that windows created on our thread inherit focus; and
87/// (b) spin a polling watcher that finds the dialog by title and calls
88/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
89#[cfg(target_os = "windows")]
90#[allow(clippy::upper_case_acronyms)]
91#[allow(dead_code)]
92mod win_dialog_focus {
93    #[cfg(feature = "native-dialog")]
94    use std::mem::size_of;
95
96    type HWND = *mut core::ffi::c_void;
97    type DWORD = u32;
98    type UINT = u32;
99    type BOOL = i32;
100
101    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
102    #[cfg(feature = "native-dialog")]
103    #[repr(C)]
104    #[allow(non_snake_case)]
105    struct FLASHWINFO {
106        cbSize: UINT,
107        hwnd: HWND,
108        dwFlags: DWORD,
109        uCount: UINT,
110        dwTimeout: DWORD,
111    }
112
113    #[cfg(feature = "native-dialog")]
114    const FLASHW_ALL: DWORD = 0x3;
115    #[cfg(feature = "native-dialog")]
116    const FLASHW_TIMERNOFG: DWORD = 0xC;
117
118    #[link(name = "user32")]
119    extern "system" {
120        fn GetForegroundWindow() -> HWND;
121        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
122        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
123        fn BringWindowToTop(hWnd: HWND) -> BOOL;
124        fn SetWindowPos(
125            hWnd: HWND,
126            hWndAfter: HWND,
127            x: i32,
128            y: i32,
129            cx: i32,
130            cy: i32,
131            flags: UINT,
132        ) -> BOOL;
133        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
134        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
135        #[cfg(feature = "native-dialog")]
136        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
137        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
138        fn FindWindowExW(
139            hWndParent: HWND,
140            hWndChildAfter: HWND,
141            lpszClass: *const u16,
142            lpszWindow: *const u16,
143        ) -> HWND;
144        // Undocumented but present on all Windows versions since XP; bypasses
145        // the foreground-lock that blocks SetForegroundWindow from non-foreground
146        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
147        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
148    }
149
150    #[link(name = "kernel32")]
151    extern "system" {
152        fn GetCurrentThreadId() -> DWORD;
153    }
154
155    #[link(name = "shell32")]
156    extern "system" {
157        // Opens a folder (or file) via the Windows shell.  Passing the current
158        // foreground window as `hwnd` gives the new window proper activation
159        // context so it surfaces in the foreground without needing
160        // AttachThreadInput or SetForegroundWindow hacks.
161        fn ShellExecuteW(
162            hwnd: HWND,
163            lpOperation: *const u16,
164            lpFile: *const u16,
165            lpParameters: *const u16,
166            lpDirectory: *const u16,
167            nShowCmd: i32,
168        ) -> isize; // HINSTANCE (>32 = success)
169    }
170
171    /// Attaches our thread's input to the foreground window's thread so that
172    /// windows created on our thread inherit foreground focus.  Returns the
173    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
174    /// the thread was already the foreground thread.
175    #[cfg(feature = "native-dialog")]
176    pub fn attach_to_foreground() -> DWORD {
177        unsafe {
178            let fg_hwnd = GetForegroundWindow();
179            if fg_hwnd.is_null() {
180                return 0;
181            }
182            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
183            let my_tid = GetCurrentThreadId();
184            if fg_tid == my_tid {
185                return 0;
186            }
187            AttachThreadInput(my_tid, fg_tid, 1);
188            fg_tid
189        }
190    }
191
192    /// Undoes `attach_to_foreground`.
193    #[cfg(feature = "native-dialog")]
194    pub fn detach_from_foreground(fg_tid: DWORD) {
195        if fg_tid == 0 {
196            return;
197        }
198        unsafe {
199            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
200        }
201    }
202
203    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
204        let mut existing = std::collections::HashSet::new();
205        let mut prev: HWND = core::ptr::null_mut();
206        loop {
207            let w = FindWindowExW(
208                core::ptr::null_mut(),
209                prev,
210                class_w.as_ptr(),
211                core::ptr::null(),
212            );
213            if w.is_null() {
214                break;
215            }
216            existing.insert(w as usize);
217            prev = w;
218        }
219        existing
220    }
221
222    unsafe fn find_new_explorer_hwnd(
223        class_w: &[u16],
224        existing: &std::collections::HashSet<usize>,
225    ) -> Option<HWND> {
226        let mut prev: HWND = core::ptr::null_mut();
227        loop {
228            let w = FindWindowExW(
229                core::ptr::null_mut(),
230                prev,
231                class_w.as_ptr(),
232                core::ptr::null(),
233            );
234            if w.is_null() {
235                return None;
236            }
237            if !existing.contains(&(w as usize)) {
238                return Some(w);
239            }
240            prev = w;
241        }
242    }
243
244    unsafe fn bring_to_front(hwnd: HWND) {
245        // Surfacing a window owned by another process (Explorer) from a
246        // background thread is blocked by Windows' foreground lock:
247        // SetForegroundWindow silently fails and only the taskbar button
248        // flashes.  The reliable workaround is to temporarily attach our input
249        // queue to the thread that currently owns the foreground window — while
250        // attached, SetForegroundWindow/BringWindowToTop actually activate the
251        // window instead of merely flashing it.
252        let my_tid = GetCurrentThreadId();
253        let fg_hwnd = GetForegroundWindow();
254        let fg_tid = if fg_hwnd.is_null() {
255            0
256        } else {
257            GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
258        };
259        let attached = fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
260
261        // SW_RESTORE = 9 — un-minimise the Explorer window (it may have opened
262        // as a taskbar button) without forcing a full-screen maximise.
263        ShowWindow(hwnd, 9);
264        BringWindowToTop(hwnd);
265        SetForegroundWindow(hwnd);
266        // Extra belt-and-braces activation that also bypasses the foreground
267        // lock on older Windows builds.
268        SwitchToThisWindow(hwnd, 1);
269
270        // Force the Z-order to the very top regardless of the foreground-lock
271        // outcome by flipping TOPMOST on then off, so the window jumps above all
272        // others without staying pinned. HWND_TOPMOST = -1, HWND_NOTOPMOST = -2;
273        // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE = 0x0013.
274        SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
275        SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
276
277        if attached {
278            AttachThreadInput(my_tid, fg_tid, 0);
279        }
280    }
281
282    /// Opens `path` in Windows Explorer and forces it to the foreground.
283    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
284    /// caller is not the foreground process (the browser is).  After launching,
285    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
286    /// an undocumented API that bypasses Windows' foreground-lock restriction
287    /// so the window surfaces regardless of which process currently has focus.
288    pub fn open_folder_foreground(path: std::path::PathBuf) {
289        std::thread::spawn(move || {
290            use std::os::windows::ffi::OsStrExt;
291
292            let op: Vec<u16> = "explore\0".encode_utf16().collect();
293            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
294            path_w.push(0);
295            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
296
297            unsafe {
298                // Snapshot every existing Explorer window before we launch so
299                // we can identify the newly created one.
300                let existing = snapshot_explorer_hwnds(&class_w);
301                let fg_hwnd = GetForegroundWindow();
302                // SW_SHOWNORMAL = 1
303                ShellExecuteW(
304                    fg_hwnd,
305                    op.as_ptr(),
306                    path_w.as_ptr(),
307                    core::ptr::null(),
308                    core::ptr::null(),
309                    1,
310                );
311
312                // Poll up to ~3 s for a new CabinetWClass window to appear,
313                // then use SwitchToThisWindow (bypasses foreground-lock) to
314                // bring it in front of the browser and everything else.
315                for _ in 0..40 {
316                    std::thread::sleep(std::time::Duration::from_millis(75));
317                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
318                        bring_to_front(w);
319                        return;
320                    }
321                }
322
323                // Fallback: Explorer reused an existing window — bring whichever
324                // CabinetWClass window is first in Z-order to the front.
325                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
326                if !w.is_null() {
327                    bring_to_front(w);
328                }
329            }
330        });
331    }
332
333    /// Spawns a short-lived watcher thread that polls for a dialog window
334    /// matching `title` and, once found, forces it to the foreground and
335    /// flashes its taskbar button until the user interacts with it.
336    #[cfg(feature = "native-dialog")]
337    pub fn flash_dialog_when_ready(title: String) {
338        std::thread::spawn(move || {
339            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
340            for _ in 0..40 {
341                std::thread::sleep(std::time::Duration::from_millis(80));
342                unsafe {
343                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
344                    if !hwnd.is_null() {
345                        SetForegroundWindow(hwnd);
346                        BringWindowToTop(hwnd);
347                        #[allow(non_snake_case)]
348                        FlashWindowEx(&FLASHWINFO {
349                            // size_of returns usize; Win32 struct field is u32 (UINT).
350                            // struct size fits trivially within u32.
351                            #[allow(clippy::cast_possible_truncation)]
352                            cbSize: size_of::<FLASHWINFO>() as UINT,
353                            hwnd,
354                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
355                            uCount: 3,
356                            dwTimeout: 0,
357                        });
358                        break;
359                    }
360                }
361            }
362        });
363    }
364}
365
366/// Sliding-window rate limiter keyed by client IP.
367/// Uses only std primitives — no external crate required.
368pub(crate) struct IpRateLimiter {
369    window: Duration,
370    max_requests: usize,
371    pub(crate) auth_lockout_threshold: u32,
372    auth_lockout_window: Duration,
373    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
374    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
375}
376
377impl IpRateLimiter {
378    pub(crate) fn new(
379        window: Duration,
380        max_requests: usize,
381        auth_lockout_threshold: u32,
382        auth_lockout_window: Duration,
383    ) -> Self {
384        Self {
385            window,
386            max_requests,
387            auth_lockout_threshold,
388            auth_lockout_window,
389            state: std::sync::Mutex::new(HashMap::new()),
390            auth_failures: std::sync::Mutex::new(HashMap::new()),
391        }
392    }
393
394    // The MutexGuard `state` must live as long as `bucket` borrows from it,
395    // so it cannot be dropped any earlier than the end of the inner block.
396    #[allow(clippy::significant_drop_tightening)]
397    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
398        let now = Instant::now();
399        let cutoff = now.checked_sub(self.window).unwrap_or(now);
400        let mut state = self
401            .state
402            .lock()
403            .unwrap_or_else(std::sync::PoisonError::into_inner);
404        if state.len() > 10_000 {
405            state.retain(|_, bucket| {
406                while bucket.front().is_some_and(|t| *t <= cutoff) {
407                    bucket.pop_front();
408                }
409                !bucket.is_empty()
410            });
411        }
412        let bucket = state.entry(ip).or_default();
413        while bucket.front().is_some_and(|t| *t <= cutoff) {
414            bucket.pop_front();
415        }
416        if bucket.len() >= self.max_requests {
417            false
418        } else {
419            bucket.push_back(now);
420            true
421        }
422    }
423
424    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
425        let now = Instant::now();
426        let mut map = self
427            .auth_failures
428            .lock()
429            .unwrap_or_else(std::sync::PoisonError::into_inner);
430        map.entry(ip)
431            .and_modify(|e| {
432                e.0 += 1;
433                e.1 = now;
434            })
435            .or_insert_with(|| (1, now));
436    }
437
438    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
439        let mut map = self
440            .auth_failures
441            .lock()
442            .unwrap_or_else(std::sync::PoisonError::into_inner);
443        let expired = map
444            .get(&ip)
445            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
446        if expired {
447            map.remove(&ip);
448            return false;
449        }
450        map.get(&ip)
451            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
452    }
453
454    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
455        let map = self
456            .auth_failures
457            .lock()
458            .unwrap_or_else(std::sync::PoisonError::into_inner);
459        map.get(&ip).map_or(0, |e| {
460            self.auth_lockout_window
461                .checked_sub(e.1.elapsed())
462                .map_or(0, |r| r.as_secs())
463        })
464    }
465
466    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
467        tokio::spawn(async move {
468            let mut interval = tokio::time::interval(Duration::from_mins(1));
469            interval.tick().await; // consume the immediate first tick
470            loop {
471                interval.tick().await;
472                let now = Instant::now();
473                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
474                {
475                    let mut state = limiter
476                        .state
477                        .lock()
478                        .unwrap_or_else(std::sync::PoisonError::into_inner);
479                    state.retain(|_, bucket| {
480                        while bucket.front().is_some_and(|t| *t <= cutoff) {
481                            bucket.pop_front();
482                        }
483                        !bucket.is_empty()
484                    });
485                }
486                {
487                    let mut auth = limiter
488                        .auth_failures
489                        .lock()
490                        .unwrap_or_else(std::sync::PoisonError::into_inner);
491                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
492                }
493            }
494        });
495    }
496}
497
498/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
499/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
500/// files but never triggers a scan.
501fn spawn_upload_staging_cleanup() {
502    tokio::spawn(async move {
503        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
504            .ok()
505            .and_then(|v| v.parse().ok())
506            .unwrap_or(4);
507        let ttl_secs = ttl_hours * 3600;
508        let mut interval = tokio::time::interval(Duration::from_hours(1));
509        interval.tick().await; // consume the immediate first tick
510        loop {
511            interval.tick().await;
512            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
513            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
514                continue;
515            };
516            while let Ok(Some(entry)) = dir.next_entry().await {
517                let path = entry.path();
518                let age_secs = tokio::fs::metadata(&path)
519                    .await
520                    .ok()
521                    .and_then(|m| m.modified().ok())
522                    .and_then(|t| t.elapsed().ok())
523                    .map_or(0, |d| d.as_secs());
524                if age_secs > ttl_secs {
525                    tracing::debug!(
526                        event = "upload_staging_cleanup",
527                        path = %path.display(),
528                        age_secs,
529                        "removing stale upload staging directory"
530                    );
531                    let _ = tokio::fs::remove_dir_all(&path).await;
532                }
533            }
534        }
535    });
536}
537
538/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
539#[derive(Clone, Debug, Default)]
540struct RunResultContext {
541    prev_entry: Option<RegistryEntry>,
542    prev_scan_count: usize,
543    project_path: String,
544    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
545    cocomo_mode: String,
546    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
547    complexity_alert: u32,
548    /// Whether duplicate files should be excluded from displayed SLOC totals.
549    #[allow(dead_code)]
550    exclude_duplicates: bool,
551}
552
553/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
554#[derive(Clone)]
555enum AsyncRunState {
556    Running {
557        started_at: std::time::Instant,
558        cancel_token: Arc<std::sync::atomic::AtomicBool>,
559        phase: Arc<std::sync::Mutex<String>>,
560        files_done: Arc<std::sync::atomic::AtomicUsize>,
561        files_total: Arc<std::sync::atomic::AtomicUsize>,
562    },
563    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
564    Complete {
565        run_id: String,
566    },
567    Failed {
568        message: String,
569    },
570    Cancelled,
571}
572
573/// A saved scan configuration profile — stores the form parameters so users can
574/// re-run a favourite scan with one click.
575#[derive(Debug, Clone, Serialize, Deserialize)]
576struct ScanProfile {
577    id: String,
578    name: String,
579    created_at: String,
580    /// The raw scan-form parameters serialized as JSON.
581    params: serde_json::Value,
582}
583
584#[derive(Debug, Clone, Default, Serialize, Deserialize)]
585struct ScanProfileStore {
586    profiles: Vec<ScanProfile>,
587}
588
589impl ScanProfileStore {
590    fn load(path: &std::path::Path) -> Self {
591        fs::read_to_string(path)
592            .ok()
593            .and_then(|s| serde_json::from_str(&s).ok())
594            .unwrap_or_default()
595    }
596
597    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
598        if let Some(parent) = path.parent() {
599            fs::create_dir_all(parent)?;
600        }
601        let json = serde_json::to_string_pretty(self)?;
602        fs::write(path, json)?;
603        Ok(())
604    }
605}
606
607#[derive(Clone)]
608pub(crate) struct AppState {
609    pub(crate) base_config: AppConfig,
610    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
611    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
612    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
613    pub(crate) registry_path: PathBuf,
614    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
615    pub(crate) server_mode: bool,
616    /// Operator explicitly accepted running server mode with no API key
617    /// (`SLOC_ALLOW_UNAUTHENTICATED=1`). When false, an unauthenticated server-mode
618    /// request fails closed with 503 instead of being served open.
619    pub(crate) allow_unauthenticated: bool,
620    pub(crate) tls_enabled: bool,
621    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
622    pub(crate) rate_limiter: Arc<IpRateLimiter>,
623    pub(crate) trust_proxy: bool,
624    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
625    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
626    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
627    /// Directory where remote repositories are cloned for git-browser scans.
628    pub(crate) git_clones_dir: PathBuf,
629    /// Persisted list of webhook / poll schedules.
630    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
631    pub(crate) schedules_path: PathBuf,
632    /// Named scan profiles saved by the user via the web UI.
633    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
634    pub(crate) scan_profiles_path: PathBuf,
635    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
636    /// Persisted Confluence integration settings.
637    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
638    pub(crate) confluence_path: PathBuf,
639    /// Directories the user has pinned for auto-scanning of external reports.
640    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
641    pub(crate) watched_dirs_path: PathBuf,
642    /// Persisted auto-cleanup policy (age/count limits + interval).
643    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
644    pub(crate) cleanup_policy_path: PathBuf,
645    /// Handle for the running cleanup background task; replaced on policy change.
646    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
647}
648
649type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
650
651/// Parameters for the fire-and-forget HTML + PDF background task.
652
653#[derive(Clone, Debug)]
654pub(crate) struct RunArtifacts {
655    output_dir: PathBuf,
656    html_path: Option<PathBuf>,
657    pdf_path: Option<PathBuf>,
658    json_path: Option<PathBuf>,
659    csv_path: Option<PathBuf>,
660    xlsx_path: Option<PathBuf>,
661    scan_config_path: Option<PathBuf>,
662    report_title: String,
663    result_context: RunResultContext,
664}
665
666#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
667fn build_router(state: AppState) -> Router {
668    let protected = Router::new()
669        .route("/", get(splash))
670        .route("/scan-setup", get(scan_setup_handler))
671        .route("/scan", get(index))
672        .route("/analyze", post(analyze_handler))
673        .route("/preview", get(preview_handler))
674        .route("/api/suggest-coverage", get(api_suggest_coverage))
675        .route("/pick-directory", get(pick_directory_handler))
676        .route("/open-path", get(open_path_handler))
677        .route("/pick-file", get(pick_file_handler))
678        .route(
679            "/api/upload-directory",
680            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
681        )
682        .route(
683            "/api/upload-file",
684            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
685        )
686        .route(
687            "/api/upload-tarball",
688            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
689            // The handler also enforces this limit during streaming so both layers agree.
690            post(upload_tarball_handler)
691                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
692        )
693        .route("/locate-report", post(locate_report_handler))
694        .route("/locate-reports-dir", post(locate_reports_dir_handler))
695        .route("/relocate-scan", post(relocate_scan_handler))
696        .route("/watched-dirs/add", post(add_watched_dir_handler))
697        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
698        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
699        .route("/view-reports", get(history_handler))
700        .route("/compare-scans", get(compare_select_handler))
701        .route("/compare", get(compare_handler))
702        .route("/multi-compare", get(multi_compare_handler))
703        .route("/images/{folder}/{file}", get(image_handler))
704        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
705        .route("/api/metrics/latest", get(api_metrics_latest_handler))
706        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
707        .route("/api/metrics/history", get(api_metrics_history_handler))
708        .route("/api/metrics/churn", get(api_metrics_churn_handler))
709        .route(
710            "/api/metrics/submodules",
711            get(api_metrics_submodules_handler),
712        )
713        .route("/api/ingest", post(api_ingest_handler))
714        .route("/api/project-history", get(project_history_handler))
715        .route("/trend-reports", get(trend_report_handler))
716        .route("/test-metrics", get(test_metrics_handler))
717        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
718        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
719        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
720        .route("/runs/result/{run_id}", get(async_run_result_handler))
721        .route("/embed/summary", get(embed_handler))
722        // ── Git browser ────────────────────────────────────────────────────────
723        .route("/git-browser", get(git_browser::git_browser_handler))
724        .route("/api/git/refs", get(git_browser::api_list_refs))
725        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
726        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
727        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
728        // The request body is the full rendered HTML report, whose size scales
729        // with file count — large repos (Compare Scans, Files, Trend, Test
730        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
731        .route(
732            "/export/pdf",
733            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
734        )
735        // ── Config export / import ─────────────────────────────────────────────
736        .route("/export-config", get(export_config_handler))
737        .route("/import-config", post(import_config_handler))
738        // ── Scan profiles ──────────────────────────────────────────────────────
739        .route("/api/scan-profiles", get(api_list_scan_profiles))
740        .route("/api/scan-profiles", post(api_save_scan_profile))
741        .route(
742            "/api/scan-profiles/{id}",
743            axum::routing::delete(api_delete_scan_profile),
744        )
745        // ── Integrations (webhooks + Confluence) ──────────────────────────────
746        .route("/integrations", get(integrations::integrations_handler))
747        .route(
748            "/webhook-setup",
749            get(|| async { axum::response::Redirect::permanent("/integrations") }),
750        )
751        .route(
752            "/confluence-setup",
753            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
754        )
755        .route("/api/schedules", get(git_webhook::api_list_schedules))
756        .route("/api/schedules", post(git_webhook::api_create_schedule))
757        .route(
758            "/api/schedules",
759            axum::routing::delete(git_webhook::api_delete_schedule),
760        )
761        .route(
762            "/api/confluence/config",
763            get(confluence::api_get_confluence_config),
764        )
765        .route(
766            "/api/confluence/config",
767            post(confluence::api_save_confluence_config),
768        )
769        .route(
770            "/api/confluence/test",
771            post(confluence::api_test_confluence),
772        )
773        .route(
774            "/api/confluence/post",
775            post(confluence::api_post_to_confluence),
776        )
777        .route(
778            "/api/confluence/wiki-markup",
779            get(confluence::api_wiki_markup),
780        )
781        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
782        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
783        .route(
784            "/api/runs/{run_id}",
785            axum::routing::delete(delete_run_handler),
786        )
787        .route("/api/runs/cleanup", post(cleanup_runs_handler))
788        // ── Auto-cleanup policy ────────────────────────────────────────────────
789        .route(
790            "/api/cleanup-policy",
791            get(api_get_cleanup_policy)
792                .post(api_save_cleanup_policy)
793                .delete(api_delete_cleanup_policy),
794        )
795        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
796        // ── REST API reference page ────────────────────────────────────────────
797        .route("/api-docs", get(api_docs_handler))
798        // ── Prometheus metrics — behind API-key auth ───────────────────────────
799        .route("/metrics", get(metrics_handler))
800        .route_layer(middleware::from_fn_with_state(
801            state.clone(),
802            auth::require_api_key,
803        ));
804
805    protected
806        .route("/healthz", get(healthz))
807        .route("/api/health", get(healthz))
808        .route("/api/version", get(api_version_handler))
809        .route("/api/openapi.yaml", get(openapi_yaml_handler))
810        .route("/llms.txt", get(llms_txt_handler))
811        .route("/llms-full.txt", get(llms_full_txt_handler))
812        .route("/badge/{metric}", get(badge_handler))
813        .route("/static/chart.js", get(chart_js_handler))
814        .route("/static/chart-report.js", get(report_chart_js_handler))
815        .route("/auth/login", get(auth::auth_login_get))
816        .route("/auth/login", post(auth::auth_login_post))
817        .route("/auth/logout", post(auth::auth_logout))
818        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
819        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
820        .route(
821            "/webhooks/github",
822            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
823        )
824        .route(
825            "/webhooks/gitlab",
826            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
827        )
828        .route(
829            "/webhooks/bitbucket",
830            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
831        )
832        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
833        .layer(middleware::from_fn(csrf_protect))
834        .layer(middleware::from_fn_with_state(
835            state.clone(),
836            add_security_headers,
837        ))
838        .layer(build_cors_layer(state.server_mode))
839        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
840        .with_state(state)
841}
842
843/// Bearer token used by `make_test_router_server_mode()` test routers.
844/// Tests that exercise server-mode paths must include this key in their requests.
845pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
846
847/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
848/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
849/// builders below start from this and override only the fields they care about.
850///
851/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
852fn test_app_state(tmp_subdir: &str) -> AppState {
853    std::env::set_var("SLOC_HEADLESS", "1");
854    let tmp = std::env::temp_dir().join(tmp_subdir);
855    AppState {
856        base_config: AppConfig::default(),
857        artifacts: Arc::new(Mutex::new(HashMap::new())),
858        async_runs: Arc::new(Mutex::new(HashMap::new())),
859        registry: Arc::new(Mutex::new(ScanRegistry::default())),
860        registry_path: tmp.join("registry.json"),
861        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
862        server_mode: false,
863        allow_unauthenticated: false,
864        tls_enabled: false,
865        api_keys: Arc::new(vec![]),
866        rate_limiter: Arc::new(IpRateLimiter::new(
867            Duration::from_mins(1),
868            600,
869            10,
870            Duration::from_hours(1),
871        )),
872        trust_proxy: false,
873        trusted_proxy_ips: vec![],
874        git_clones_dir: tmp.join("git-clones"),
875        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
876        schedules_path: tmp.join("schedules.json"),
877        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
878        scan_profiles_path: tmp.join("scan_profiles.json"),
879        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
880        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
881        confluence_path: tmp.join("confluence_config.json"),
882        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
883        watched_dirs_path: tmp.join("watched_dirs.json"),
884        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
885        cleanup_policy_path: tmp.join("cleanup_policy.json"),
886        cleanup_task_handle: Arc::new(Mutex::new(None)),
887    }
888}
889
890/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
891pub fn make_test_router() -> Router {
892    build_router(test_app_state("sloc_test"))
893}
894
895/// Test router with one API key pre-loaded. Used by auth integration tests.
896pub fn make_test_router_with_key(api_key: &str) -> Router {
897    let mut state = test_app_state("sloc_test_key");
898    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
899    build_router(state)
900}
901
902/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
903/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
904/// preview restrictions.
905pub fn make_test_router_server_mode() -> Router {
906    let mut state = test_app_state("sloc_test_server");
907    state.server_mode = true;
908    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
909        TEST_SERVER_MODE_API_KEY.to_owned(),
910    ))]);
911    build_router(state)
912}
913
914/// Server-mode test router with `allowed_scan_roots` configured.
915///
916/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
917/// success, unresolved path, and out-of-root rejection) that the empty-roots
918/// router cannot reach.
919pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
920    let mut state = test_app_state("sloc_test_server_roots");
921    state.server_mode = true;
922    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
923        TEST_SERVER_MODE_API_KEY.to_owned(),
924    ))]);
925    state.base_config.discovery.allowed_scan_roots = roots;
926    build_router(state)
927}
928
929/// Test router where the analysis semaphore is pre-exhausted (0 permits).
930/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
931pub fn make_test_router_exhausted_semaphore() -> Router {
932    let mut state = test_app_state("sloc_test_exhaust");
933    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
934    build_router(state)
935}
936
937/// Test router with a very tight rate limit (3 req/min). The third request from
938/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
939pub fn make_test_router_tight_rate_limit() -> Router {
940    let mut state = test_app_state("sloc_test_rate");
941    state.rate_limiter = Arc::new(IpRateLimiter::new(
942        Duration::from_mins(1),
943        2,
944        5,
945        Duration::from_secs(5),
946    ));
947    build_router(state)
948}
949
950/// Test router with a very tight auth lockout (threshold=2, window=200ms).
951/// Used by tests that need to trigger and verify the auth lockout response.
952pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
953    let mut state = test_app_state("sloc_test_auth_lockout");
954    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
955    state.rate_limiter = Arc::new(IpRateLimiter::new(
956        Duration::from_mins(1),
957        600,
958        2,                          // 2 failures triggers lockout
959        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
960    ));
961    build_router(state)
962}
963
964struct RuntimeSecurityConfig {
965    api_keys: Vec<secrecy::SecretBox<String>>,
966    tls_cert: Option<String>,
967    tls_key: Option<String>,
968    tls_enabled: bool,
969    trust_proxy: bool,
970    trusted_proxy_ips: Vec<IpAddr>,
971    rate_limiter: Arc<IpRateLimiter>,
972}
973
974/// Whether the operator has explicitly opted into running server mode with no API key.
975/// This is the single escape hatch for the fail-closed server-mode auth requirement.
976fn allow_unauthenticated_server_mode() -> bool {
977    matches!(
978        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
979        Ok("1") | Ok("true") | Ok("TRUE")
980    )
981}
982
983/// Fail-closed startup gate: refuse to launch a network-facing server that has no
984/// authentication configured, unless the operator explicitly accepted the risk.
985/// Desktop/local mode (`server_mode == false`) is always allowed.
986fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
987    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
988}
989
990/// Emit operator-facing warnings for insecure server-mode configurations.
991/// Pure side-effect (stdout); no bearing on the returned config values.
992fn emit_server_mode_warnings(
993    server_mode: bool,
994    api_keys_empty: bool,
995    tls_enabled: bool,
996    trust_proxy: bool,
997    trusted_proxy_ips: &[IpAddr],
998) {
999    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1000        // Absence of a key is a hard startup failure in server mode (enforced by the
1001        // caller, `serve`). The only exception is an explicit operator opt-in via
1002        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
1003        println!(
1004            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1005             authentication. Every web endpoint is publicly reachable. Do NOT use this \
1006             outside a trusted, isolated network."
1007        );
1008    }
1009    if server_mode && !tls_enabled {
1010        println!(
1011            "WARNING: TLS is not configured. Traffic is cleartext. \
1012             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1013             or terminate TLS at a reverse proxy (nginx, caddy)."
1014        );
1015    }
1016    if server_mode {
1017        println!(
1018            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1019             to restrict cross-origin access (comma-separated)."
1020        );
1021    }
1022    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1023    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1024        println!(
1025            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1026             DISABLED for all git operations. Remove this variable before production use."
1027        );
1028    }
1029}
1030
1031/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
1032fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1033    if trust_proxy {
1034        if trusted_proxy_ips.is_empty() {
1035            println!(
1036                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1037                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1038                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1039            );
1040        } else {
1041            println!(
1042                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1043                trusted_proxy_ips
1044                    .iter()
1045                    .map(std::string::ToString::to_string)
1046                    .collect::<Vec<_>>()
1047                    .join(", ")
1048            );
1049        }
1050    } else if server_mode {
1051        println!(
1052            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1053             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1054             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1055             enable per-client rate limiting via X-Forwarded-For."
1056        );
1057    }
1058}
1059
1060fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1061    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1062        .or_else(|_| std::env::var("SLOC_API_KEY"))
1063        .unwrap_or_default()
1064        .split(',')
1065        .map(str::trim)
1066        .filter(|s| !s.is_empty())
1067        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1068        .collect();
1069    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1070    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1071    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1072    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1073    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1074        .unwrap_or_default()
1075        .split(',')
1076        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1077        .collect();
1078    emit_server_mode_warnings(
1079        server_mode,
1080        api_keys.is_empty(),
1081        tls_enabled,
1082        trust_proxy,
1083        &trusted_proxy_ips,
1084    );
1085    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1086        .ok()
1087        .and_then(|v| v.parse::<u32>().ok())
1088        .unwrap_or(10);
1089    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1090        .ok()
1091        .and_then(|v| v.parse::<u64>().ok())
1092        .unwrap_or(3600);
1093    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1094    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1095    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1096    let default_rpm: usize = if server_mode { 120 } else { 600 };
1097    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1098        .ok()
1099        .and_then(|v| v.parse::<usize>().ok())
1100        .unwrap_or(default_rpm);
1101    let rate_limiter = Arc::new(IpRateLimiter::new(
1102        Duration::from_mins(1),
1103        rate_limit_rpm,
1104        auth_lockout_threshold,
1105        Duration::from_secs(auth_lockout_secs),
1106    ));
1107    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1108    RuntimeSecurityConfig {
1109        api_keys,
1110        tls_cert,
1111        tls_key,
1112        tls_enabled,
1113        trust_proxy,
1114        trusted_proxy_ips,
1115        rate_limiter,
1116    }
1117}
1118
1119/// # Errors
1120///
1121/// Returns an error if the server fails to bind to the configured address or
1122/// if the TLS configuration cannot be loaded.
1123///
1124/// # Panics
1125///
1126/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1127#[allow(clippy::too_many_lines)]
1128pub async fn serve(config: AppConfig) -> Result<()> {
1129    let bind_address = config.web.bind_address.clone();
1130    let server_mode = config.web.server_mode;
1131    let output_root = resolve_output_root(None);
1132    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1133    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1134        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1135    let mut registry = ScanRegistry::load(&registry_path);
1136    registry.prune_stale();
1137    let _ = registry.save(&registry_path);
1138
1139    let sec = load_runtime_security_config(server_mode);
1140    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
1141    // launch with no API key would expose every endpoint publicly; fail closed unless the
1142    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
1143    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1144        audit::record(
1145            "server_start_refused",
1146            "denied",
1147            &[(
1148                "reason",
1149                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1150            )],
1151        );
1152        anyhow::bail!(
1153            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1154             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1155             unauthenticated server on a trusted, isolated network, explicitly set \
1156             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1157        );
1158    }
1159    if server_mode && sec.api_keys.is_empty() {
1160        audit::record("server_start_unauthenticated", "warning", &[]);
1161    }
1162    spawn_upload_staging_cleanup();
1163
1164    let git_clones_dir = resolve_git_clones_dir(&output_root);
1165    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1166        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1167    let schedules = ScheduleStore::load(&schedules_path);
1168    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1169        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1170    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1171    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1172        |_| output_root.join("confluence_config.json"),
1173        PathBuf::from,
1174    );
1175    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1176    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1177        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1178    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1179    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1180        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1181    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1182
1183    let state = AppState {
1184        base_config: config,
1185        artifacts: Arc::new(Mutex::new(HashMap::new())),
1186        async_runs: Arc::new(Mutex::new(HashMap::new())),
1187        registry: Arc::new(Mutex::new(registry)),
1188        registry_path,
1189        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1190        server_mode,
1191        allow_unauthenticated: allow_unauthenticated_server_mode(),
1192        tls_enabled: sec.tls_enabled,
1193        api_keys: Arc::new(sec.api_keys),
1194        rate_limiter: sec.rate_limiter,
1195        trust_proxy: sec.trust_proxy,
1196        trusted_proxy_ips: sec.trusted_proxy_ips,
1197        git_clones_dir,
1198        schedules: Arc::new(Mutex::new(schedules)),
1199        schedules_path,
1200        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1201        scan_profiles_path,
1202        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1203        confluence: Arc::new(Mutex::new(confluence)),
1204        confluence_path,
1205        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1206        watched_dirs_path,
1207        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1208        cleanup_policy_path,
1209        cleanup_task_handle: Arc::new(Mutex::new(None)),
1210    };
1211
1212    restart_poll_schedules(&state).await;
1213    warn_insecure_gitlab_webhooks(&state).await;
1214
1215    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1216    {
1217        let enabled = state
1218            .cleanup_policy
1219            .lock()
1220            .await
1221            .policy
1222            .as_ref()
1223            .is_some_and(|p| p.enabled);
1224        if enabled {
1225            let handle = spawn_cleanup_policy_task(state.clone());
1226            *state.cleanup_task_handle.lock().await = Some(handle);
1227        }
1228    }
1229
1230    let app = build_router(state.clone());
1231
1232    // Try the configured port first, then step up through a few alternatives.
1233    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1234    // kernel zombie (visible in netstat but owned by no living process).  Rather
1235    // than failing, we auto-select the next free port and tell the user.
1236    let preferred: SocketAddr = bind_address
1237        .parse()
1238        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1239    let (listener, addr) = {
1240        let candidates = (0u16..=9).map(|offset| {
1241            let mut a = preferred;
1242            a.set_port(preferred.port().saturating_add(offset));
1243            a
1244        });
1245        let mut found = None;
1246        for candidate in candidates {
1247            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1248                found = Some((l, candidate));
1249                break;
1250            }
1251        }
1252        found.ok_or_else(|| {
1253            anyhow::anyhow!(
1254                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1255                bind_address,
1256                preferred.port(),
1257                preferred.port().saturating_add(9)
1258            )
1259        })?
1260    };
1261    if addr != preferred {
1262        eprintln!(
1263            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1264             using {} instead.",
1265            preferred.port(),
1266            addr.port()
1267        );
1268    }
1269
1270    if sec.tls_enabled {
1271        let cert_path = sec
1272            .tls_cert
1273            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1274        let key_path = sec
1275            .tls_key
1276            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1277        let tls_config = build_tls_config(&cert_path, &key_path)
1278            .context("failed to load TLS certificate/key")?;
1279        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1280
1281        let url = format!("https://{addr}/");
1282        println!("OxideSLOC server running at {url} (TLS)");
1283        println!("Use Ctrl+C to stop.");
1284
1285        return serve_tls(listener, app, acceptor, server_mode).await;
1286    }
1287
1288    let url = format!("http://{addr}/");
1289    log_startup_url(&url, server_mode);
1290
1291    axum::serve(
1292        listener,
1293        app.into_make_service_with_connect_info::<SocketAddr>(),
1294    )
1295    .with_graceful_shutdown(shutdown_signal(server_mode))
1296    .await
1297    .context("web server terminated unexpectedly")
1298}
1299
1300/// Discover the primary non-loopback IPv4 address by asking the OS which
1301/// outbound interface it would use to reach a public address.  No packets are
1302/// sent — the UDP socket is only used to query the routing table.
1303fn primary_lan_ip() -> Option<String> {
1304    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1305    socket.connect("8.8.8.8:80").ok()?;
1306    let addr = socket.local_addr().ok()?;
1307    let ip = addr.ip();
1308    if ip.is_loopback() {
1309        return None;
1310    }
1311    Some(ip.to_string())
1312}
1313
1314/// Print the startup URL and, in local mode, open the browser and schedule it.
1315fn log_startup_url(url: &str, server_mode: bool) {
1316    if server_mode {
1317        println!("OxideSLOC server running at {url}");
1318        println!("Use Ctrl+C to stop.");
1319    } else {
1320        println!("OxideSLOC local web UI running at {url}");
1321        println!("Press Ctrl+C to stop the server.");
1322        let open_url = url.to_owned();
1323        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1324    }
1325}
1326
1327/// Open the given URL in the default system browser.
1328fn open_browser_tab(url: &str) {
1329    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1330    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1331    // first quoted token as a window title — both are fragile and shell-parsed. The
1332    // url.dll handler receives the URL as a single, non-shell argument.
1333    #[cfg(target_os = "windows")]
1334    let _ = std::process::Command::new("rundll32")
1335        .args(["url.dll,FileProtocolHandler", url])
1336        .stdout(Stdio::null())
1337        .stderr(Stdio::null())
1338        .spawn();
1339    #[cfg(target_os = "macos")]
1340    let _ = std::process::Command::new("open")
1341        .arg(url)
1342        .stdout(Stdio::null())
1343        .stderr(Stdio::null())
1344        .spawn();
1345    #[cfg(target_os = "linux")]
1346    let _ = std::process::Command::new("xdg-open")
1347        .arg(url)
1348        .stdout(Stdio::null())
1349        .stderr(Stdio::null())
1350        .spawn();
1351}
1352
1353/// Graceful-shutdown future: resolves on Ctrl-C.
1354async fn shutdown_signal(server_mode: bool) {
1355    if tokio::signal::ctrl_c().await.is_ok() {
1356        println!();
1357        if server_mode {
1358            println!("Shutting down OxideSLOC server...");
1359        } else {
1360            println!("Shutting down OxideSLOC local web UI...");
1361        }
1362        println!("Server stopped cleanly.");
1363    }
1364}
1365
1366/// Load a rustls `ServerConfig` from PEM certificate and key files.
1367fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1368    use rustls_pki_types::pem::PemObject;
1369    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1370
1371    let cert_bytes =
1372        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1373    let key_bytes =
1374        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1375
1376    let cert_chain: Vec<CertificateDer<'static>> =
1377        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1378            .collect::<std::result::Result<_, _>>()
1379            .context("failed to parse TLS certificates")?;
1380
1381    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1382        .context("failed to parse TLS private key")?;
1383
1384    rustls::ServerConfig::builder()
1385        .with_no_client_auth()
1386        .with_single_cert(cert_chain, key)
1387        .context("failed to build TLS server config")
1388}
1389
1390/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1391async fn serve_tls(
1392    listener: tokio::net::TcpListener,
1393    app: Router,
1394    acceptor: tokio_rustls::TlsAcceptor,
1395    server_mode: bool,
1396) -> Result<()> {
1397    use hyper_util::rt::{TokioExecutor, TokioIo};
1398    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1399    use hyper_util::service::TowerToHyperService;
1400    use tower::{Service, ServiceExt};
1401
1402    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1403
1404    loop {
1405        tokio::select! {
1406            biased;
1407            _ = tokio::signal::ctrl_c() => {
1408                println!();
1409                if server_mode {
1410                    println!("Shutting down OxideSLOC server...");
1411                } else {
1412                    println!("Shutting down OxideSLOC local web UI...");
1413                }
1414                println!("Server stopped cleanly.");
1415                return Ok(());
1416            }
1417            result = listener.accept() => {
1418                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1419                let acceptor = acceptor.clone();
1420                let mut factory = make_svc.clone();
1421
1422                tokio::spawn(async move {
1423                    let tls = match acceptor.accept(tcp).await {
1424                        Ok(s) => s,
1425                        Err(e) => {
1426                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1427                            return;
1428                        }
1429                    };
1430                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1431                        Ok(f) => match Service::call(f, peer_addr).await {
1432                            Ok(s) => s,
1433                            Err(_) => return,
1434                        },
1435                        Err(_) => return,
1436                    };
1437                    let io = TokioIo::new(tls);
1438                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1439                        .serve_connection(io, TowerToHyperService::new(svc))
1440                        .await
1441                    {
1442                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1443                    }
1444                });
1445            }
1446        }
1447    }
1448}
1449
1450// auth moved to auth.rs
1451
1452fn build_cors_layer(server_mode: bool) -> CorsLayer {
1453    if server_mode {
1454        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1455            .unwrap_or_default()
1456            .split(',')
1457            .filter(|s| !s.is_empty())
1458            .filter_map(|s| s.trim().parse().ok())
1459            .collect();
1460        if allowed.is_empty() {
1461            return CorsLayer::new();
1462        }
1463        CorsLayer::new()
1464            .allow_origin(AllowOrigin::list(allowed))
1465            .allow_methods(AllowMethods::list([
1466                axum::http::Method::GET,
1467                axum::http::Method::POST,
1468            ]))
1469            .allow_headers(AllowHeaders::list([
1470                axum::http::header::AUTHORIZATION,
1471                axum::http::header::CONTENT_TYPE,
1472            ]))
1473    } else {
1474        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1475            let s = origin.to_str().unwrap_or("");
1476            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1477        }))
1478    }
1479}
1480
1481async fn add_security_headers(
1482    State(state): State<AppState>,
1483    mut req: Request<Body>,
1484    next: Next,
1485) -> Response {
1486    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1487    req.extensions_mut().insert(CspNonce(nonce.clone()));
1488    let mut resp = next.run(req).await;
1489    inject_page_fade_into_html(&mut resp, &nonce).await;
1490    let h = resp.headers_mut();
1491    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1492    h.insert(
1493        "X-Content-Type-Options",
1494        HeaderValue::from_static("nosniff"),
1495    );
1496    h.insert(
1497        "Referrer-Policy",
1498        HeaderValue::from_static("strict-origin-when-cross-origin"),
1499    );
1500    let csp = format!(
1501        "default-src 'self'; \
1502         base-uri 'self'; \
1503         form-action 'self'; \
1504         style-src 'self' 'unsafe-inline'; \
1505         img-src 'self' data: blob:; \
1506         script-src 'self' 'nonce-{nonce}'; \
1507         font-src 'self' data:; \
1508         object-src 'none'; \
1509         frame-ancestors 'none'"
1510    );
1511    h.insert(
1512        "Content-Security-Policy",
1513        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1514            HeaderValue::from_static(
1515                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1516            )
1517        }),
1518    );
1519    h.insert(
1520        "X-Permitted-Cross-Domain-Policies",
1521        HeaderValue::from_static("none"),
1522    );
1523    h.insert(
1524        "Permissions-Policy",
1525        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1526    );
1527    h.insert(
1528        "Cross-Origin-Opener-Policy",
1529        HeaderValue::from_static("same-origin"),
1530    );
1531    h.insert(
1532        "Cross-Origin-Resource-Policy",
1533        HeaderValue::from_static("same-origin"),
1534    );
1535    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1536    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1537    h.insert(
1538        "Cross-Origin-Embedder-Policy",
1539        HeaderValue::from_static("require-corp"),
1540    );
1541    if state.tls_enabled {
1542        h.insert(
1543            "Strict-Transport-Security",
1544            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1545        );
1546    }
1547    resp
1548}
1549
1550/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1551///
1552/// On state-changing methods, browser-driven cookie-authenticated requests must
1553/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1554/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1555///
1556/// Deliberately exempt:
1557/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1558/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1559///   ambient, so it is not CSRF-exploitable.
1560/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1561/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1562///   a browser performing a CSRF attack always sends `Origin`.
1563async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1564    use axum::http::Method;
1565
1566    let is_state_changing = matches!(
1567        *req.method(),
1568        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1569    );
1570    let path = req.uri().path();
1571    let has_token_auth = req.headers().contains_key("X-API-Key")
1572        || req
1573            .headers()
1574            .get(header::AUTHORIZATION)
1575            .and_then(|v| v.to_str().ok())
1576            .is_some_and(|v| v.starts_with("Bearer "));
1577
1578    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1579        return next.run(req).await;
1580    }
1581
1582    let headers = req.headers();
1583    let header_str = |name: &header::HeaderName| {
1584        headers
1585            .get(name)
1586            .and_then(|v| v.to_str().ok())
1587            .map(str::to_owned)
1588    };
1589    let origin = header_str(&header::ORIGIN);
1590    let referer = header_str(&header::REFERER);
1591    let host = header_str(&header::HOST);
1592
1593    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1594    let authority_of = |url: &str| -> Option<String> {
1595        url.split_once("://")
1596            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1597    };
1598
1599    let source_authority = origin
1600        .as_deref()
1601        .and_then(authority_of)
1602        .or_else(|| referer.as_deref().and_then(authority_of));
1603
1604    match (source_authority, host) {
1605        // Neither Origin nor Referer present: treat as a non-browser client.
1606        (None, _) => next.run(req).await,
1607        (Some(src), Some(h)) if src == h => next.run(req).await,
1608        (Some(src), host) => {
1609            tracing::warn!(
1610                event = "csrf_rejected",
1611                path = %path,
1612                origin = %src,
1613                host = ?host,
1614                "Cross-origin state-changing request rejected (CSRF guard)"
1615            );
1616            (
1617                StatusCode::FORBIDDEN,
1618                "403 Forbidden — cross-origin request rejected\n",
1619            )
1620                .into_response()
1621        }
1622    }
1623}
1624
1625/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1626/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1627/// is overkill — a short opacity fade gives a smooth page-to-page transition
1628/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1629/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1630/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1631/// light-mode flash for dark-theme users.
1632fn page_fade_html(nonce: &str) -> String {
1633    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1634    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1635    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1636    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1637    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1638    // through the entire body parse, which reads as a delay before navigation "begins"
1639    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1640    // 0 -> 1 cleanly, with no pre-paint hold.
1641    const STYLE: &str = r"<style>
1642@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1643.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1644body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1645@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1646</style>";
1647    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1648    // The click handler gives immediate feedback by fading the *content* out the moment a
1649    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1650    // preventDefault or delay navigation — the browser navigates instantly and the fade
1651    // plays opportunistically during the natural fetch window, so no latency is added.
1652    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1653    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1654    // if the click was actually a download (no unload) or the page is restored from bfcache.
1655    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');});})();";
1656    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1657}
1658
1659/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1660/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1661/// `<script>` — meant to be spliced in immediately after `<body>`.
1662///
1663/// It pairs the spinner with a **visibility gate**: from the first byte the page
1664/// content is held at `visibility:hidden` (only the overlay paints), so the user
1665/// never sees a half-rendered flash while charts/tables are still settling. On
1666/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1667/// still-opaque overlay, which then fades out one frame later — so the reveal is
1668/// of a finished page, with no glitch on either side of the transition.
1669///
1670/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1671/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1672/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1673fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1674    const TPL: &str = r#"<style nonce="__N__">
1675html.sloc-pending body{visibility:hidden;}
1676html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1677#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%);}
1678#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1679body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1680body.pdf-mode #rpt-loading-overlay{display:none!important;}
1681.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1682.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;}
1683.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;}
1684@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1685@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1686body.dark-theme .rpt-bg-blob{opacity:.36;}
1687.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;}
1688@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1689body.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);}
1690.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1691.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1692.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1693.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));}
1694@keyframes rpt-spin{to{transform:rotate(360deg);}}
1695.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;}
1696body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1697body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1698.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1699.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;}
1700@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1701.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1702.rpt-dot:nth-child(2){animation-delay:.28s;}
1703.rpt-dot:nth-child(3){animation-delay:.56s;}
1704@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1705.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1706.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1707.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;}
1708body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1709@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;}}
1710</style>
1711<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1712<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>
1713<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1714  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1715  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1716  <div class="rpt-load-card">
1717    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1718    <div class="rpt-spinner-wrap">
1719      <div class="rpt-spinner-track"></div>
1720      <div class="rpt-spinner"></div>
1721      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1722    </div>
1723    <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>
1724    <div class="rpt-status" id="rpt-status">__LABEL__</div>
1725    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1726  </div>
1727</div>
1728<script nonce="__N__">
1729(function(){
1730  var ov=document.getElementById('rpt-loading-overlay');
1731  var root=document.documentElement;
1732  function reveal(){root.classList.remove('sloc-pending');}
1733  if(!ov){reveal();return;}
1734  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1735  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
1736  var mi=0,prog=0,done=false,start=Date.now();
1737  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
1738  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
1739  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
1740  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
1741  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
1742  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
1743  setProg(8);
1744  var msgTimer=setInterval(nextMsg,700);
1745  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);
1746  // These pages draw charts into known SVG containers that start empty and are
1747  // filled by JS once layout is available (some only after a ResizeObserver pass
1748  // post-`load`). Treat the page as ready only once every chart container present
1749  // actually has rendered content, so the overlay never lifts on a half-drawn page.
1750  function chartsRendered(){
1751    var sel=['#cmp-tl-svg','#mc-chart'];
1752    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
1753    return true;
1754  }
1755  function finish(){
1756    if(done)return;done=true;
1757    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
1758    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
1759    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
1760    reveal();
1761    requestAnimationFrame(function(){requestAnimationFrame(function(){
1762      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
1763    });});
1764  }
1765  // Wait for `load` (resources + first layout), then poll until the charts have
1766  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
1767  function afterLoad(){
1768    var loadAt=Date.now();
1769    (function poll(){
1770      if(done)return;
1771      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
1772        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
1773        return;
1774      }
1775      requestAnimationFrame(poll);
1776    })();
1777  }
1778  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
1779  // Absolute safety net: never let the gate/overlay get stuck.
1780  setTimeout(function(){if(!done)finish();},HARD_CAP);
1781})();
1782</script>"#;
1783    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
1784}
1785
1786/// Shared toast-notification assets + a global PDF-export helper, spliced into
1787/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
1788/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
1789/// placed just before `</body>`.
1790///
1791/// It defines two globals:
1792/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
1793///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
1794///   toast stays up until its returned handle's `.dismiss()` is called.
1795/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
1796///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
1797///   `/export/pdf`, triggers the download, then raises a success or error toast and
1798///   restores the button. Centralising this guarantees identical, obvious feedback
1799///   everywhere instead of a silent `alert()`-only failure path.
1800fn sloc_toast_assets(nonce: &str) -> String {
1801    const TPL: &str = r#"<style nonce="__N__">
1802#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;}
1803.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);}
1804.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
1805.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
1806.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;}
1807.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
1808.sloc-toast-error .sloc-toast-ico{background:#b23030;}
1809.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
1810.sloc-toast-success{border-color:#bfe0cc;}
1811.sloc-toast-error{border-color:#e6b3b3;}
1812.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
1813.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;}
1814@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
1815.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;}
1816.sloc-toast-x:hover{opacity:1;}
1817body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
1818body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
1819body.dark-theme .sloc-toast-error{border-color:#6e3434;}
1820body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
1821@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
1822</style>
1823<script nonce="__N__">
1824(function(){
1825  if(window.slocToast)return;
1826  function wrap(){
1827    var w=document.getElementById('sloc-toast-wrap');
1828    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);}
1829    return w;
1830  }
1831  window.slocToast=function(msg,opts){
1832    opts=opts||{};
1833    var type=opts.type||'info';
1834    var loading=type==='loading';
1835    var t=document.createElement('div');
1836    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
1837    t.setAttribute('role',type==='error'?'alert':'status');
1838    var ico=loading
1839      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
1840      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
1841    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
1842    t.querySelector('.sloc-toast-msg').textContent=String(msg);
1843    wrap().appendChild(t);
1844    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
1845    var gone=false,timer=null;
1846    function close(){
1847      if(gone)return;gone=true;if(timer)clearTimeout(timer);
1848      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
1849      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
1850    }
1851    t.querySelector('.sloc-toast-x').addEventListener('click',close);
1852    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
1853    if(ttl>0)timer=setTimeout(close,ttl);
1854    return {dismiss:close,el:t};
1855  };
1856  window.slocExportPdf=function(o){
1857    o=o||{};
1858    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
1859    if(btn&&btn.disabled)return;
1860    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
1861    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
1862    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
1863      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
1864      .then(function(blob){
1865        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
1866        document.body.appendChild(a);a.click();document.body.removeChild(a);
1867        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
1868        load.dismiss();
1869        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
1870      })
1871      .catch(function(e){
1872        load.dismiss();
1873        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
1874      })
1875      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
1876  };
1877})();
1878</script>"#;
1879    TPL.replace("__N__", nonce)
1880}
1881
1882/// Buffer an HTML response body and splice the page fade-in right after the
1883/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
1884/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
1885/// branded loading spinner for slow renders).
1886async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
1887    let is_html = resp
1888        .headers()
1889        .get(header::CONTENT_TYPE)
1890        .and_then(|v| v.to_str().ok())
1891        .is_some_and(|v| v.starts_with("text/html"));
1892    if !is_html {
1893        return;
1894    }
1895    let body = std::mem::replace(resp.body_mut(), Body::empty());
1896    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
1897        return;
1898    };
1899    let html = match String::from_utf8(bytes.to_vec()) {
1900        Ok(s) => s,
1901        Err(e) => {
1902            *resp.body_mut() = Body::from(e.into_bytes());
1903            return;
1904        }
1905    };
1906    if html.contains("id=\"rpt-loading-overlay\"") {
1907        *resp.body_mut() = Body::from(html);
1908        return;
1909    }
1910    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
1911    // avoids allocating a lowercased copy of the whole document on every request.
1912    // Fall back to a case-insensitive scan only if that fails (rare/never).
1913    let insert_at = html
1914        .find("<body")
1915        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
1916        .or_else(|| {
1917            let lower = html.to_ascii_lowercase();
1918            lower
1919                .find("<body")
1920                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
1921        });
1922    let new_html = match insert_at {
1923        Some(at) => {
1924            let mut out = String::with_capacity(html.len() + 1024);
1925            out.push_str(&html[..at]);
1926            out.push_str(&page_fade_html(nonce));
1927            out.push_str(&html[at..]);
1928            out
1929        }
1930        None => html,
1931    };
1932    resp.headers_mut().remove(header::CONTENT_LENGTH);
1933    *resp.body_mut() = Body::from(new_html);
1934}
1935
1936async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1937    let peer_ip = req
1938        .extensions()
1939        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1940        .map(|c| c.0.ip());
1941
1942    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
1943    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
1944    // header spoofing from direct connections.
1945    let ip = peer_ip
1946        .and_then(|peer| {
1947            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
1948                req.headers()
1949                    .get("X-Forwarded-For")
1950                    .and_then(|v| v.to_str().ok())
1951                    .and_then(|s| s.split(',').next())
1952                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1953            } else {
1954                None
1955            }
1956        })
1957        .or(peer_ip)
1958        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1959
1960    if !state.rate_limiter.is_allowed(ip) {
1961        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1962            path = %req.uri().path(), "Rate limit exceeded");
1963        return (
1964            StatusCode::TOO_MANY_REQUESTS,
1965            [(header::RETRY_AFTER, "60")],
1966            "429 Too Many Requests\n",
1967        )
1968            .into_response();
1969    }
1970    next.run(req).await
1971}
1972
1973async fn splash(
1974    State(state): State<AppState>,
1975    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1976) -> impl IntoResponse {
1977    let lan_ip = if state.server_mode {
1978        primary_lan_ip()
1979    } else {
1980        None
1981    };
1982    let port = state
1983        .base_config
1984        .web
1985        .bind_address
1986        .rsplit(':')
1987        .next()
1988        .and_then(|p| p.parse::<u16>().ok())
1989        .unwrap_or(4317);
1990    let has_api_key = !state.api_keys.is_empty();
1991    let template = SplashTemplate {
1992        csp_nonce,
1993        server_mode: state.server_mode,
1994        lan_ip,
1995        port,
1996        version: env!("CARGO_PKG_VERSION"),
1997        has_api_key,
1998    };
1999    Html(
2000        template
2001            .render()
2002            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2003    )
2004}
2005
2006async fn index(
2007    State(state): State<AppState>,
2008    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2009    Query(query): Query<IndexQuery>,
2010) -> impl IntoResponse {
2011    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2012        let policy = query
2013            .mixed_line_policy
2014            .unwrap_or_else(|| "code_only".to_string());
2015        let behavior = query
2016            .binary_file_behavior
2017            .unwrap_or_else(|| "skip".to_string());
2018        let cfg = ScanConfig {
2019            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2020            path: query.path.unwrap_or_default(),
2021            include_globs: query.include_globs.unwrap_or_default(),
2022            exclude_globs: query.exclude_globs.unwrap_or_default(),
2023            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2024            mixed_line_policy: policy,
2025            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2026                != Some("off"),
2027            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2028            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2029            vendor_directory_detection: query.vendor_directory_detection.as_deref()
2030                != Some("disabled"),
2031            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2032            binary_file_behavior: behavior,
2033            output_dir: query.output_dir.unwrap_or_default(),
2034            report_title: query.report_title.unwrap_or_default(),
2035            continuation_line_policy: query
2036                .continuation_line_policy
2037                .unwrap_or_else(default_each_physical_line),
2038            blank_in_block_comment_policy: query
2039                .blank_in_block_comment_policy
2040                .unwrap_or_else(default_count_as_comment),
2041            count_compiler_directives: query.count_compiler_directives.as_deref()
2042                != Some("disabled"),
2043            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2044            style_col_threshold: query
2045                .style_col_threshold
2046                .as_deref()
2047                .and_then(|s| s.parse().ok())
2048                .unwrap_or(80),
2049            style_score_threshold: query
2050                .style_score_threshold
2051                .as_deref()
2052                .and_then(|s| s.parse().ok())
2053                .unwrap_or(0),
2054            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2055            coverage_file: query.coverage_file.unwrap_or_default(),
2056            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2057            complexity_alert: query
2058                .complexity_alert
2059                .as_deref()
2060                .and_then(|s| s.parse().ok())
2061                .unwrap_or(0),
2062            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2063            activity_window: query
2064                .activity_window
2065                .as_deref()
2066                .and_then(|s| s.parse().ok())
2067                .unwrap_or(90),
2068        };
2069        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2070    } else {
2071        "{}".to_string()
2072    };
2073
2074    let git_repo = query.git_repo.unwrap_or_default();
2075    let git_ref = query.git_ref.unwrap_or_default();
2076
2077    let git_label = make_git_label(&git_repo, &git_ref);
2078    let git_output_dir = if git_label.is_empty() {
2079        String::new()
2080    } else {
2081        desktop_dir().join(&git_label).display().to_string()
2082    };
2083    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2084    let git_output_dir_json =
2085        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2086
2087    let template = IndexTemplate {
2088        version: env!("CARGO_PKG_VERSION"),
2089        prefill_json,
2090        csp_nonce,
2091        git_repo,
2092        git_ref,
2093        git_label_json,
2094        git_output_dir_json,
2095        server_mode: state.server_mode,
2096    };
2097
2098    Html(
2099        template
2100            .render()
2101            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2102    )
2103}
2104
2105async fn scan_setup_handler(
2106    State(state): State<AppState>,
2107    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2108) -> impl IntoResponse {
2109    let recent_scans_json = {
2110        let arr: Vec<serde_json::Value> = {
2111            let reg = state.registry.lock().await;
2112            reg.entries
2113                .iter()
2114                .rev()
2115                .take(6)
2116                .map(|e| {
2117                    let run_dir = e
2118                        .html_path
2119                        .as_ref()
2120                        .or(e.json_path.as_ref())
2121                        .and_then(|p| p.parent().map(PathBuf::from));
2122                    let config_val: Option<serde_json::Value> = run_dir
2123                        .and_then(|d| find_scan_config_in_dir(&d))
2124                        .and_then(|p| fs::read_to_string(&p).ok())
2125                        .and_then(|s| serde_json::from_str(&s).ok());
2126                    serde_json::json!({
2127                        "project_label": e.project_label,
2128                        "timestamp": fmt_la_time(e.timestamp_utc),
2129                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2130                        "config": config_val,
2131                    })
2132                })
2133                .collect()
2134        };
2135        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2136    };
2137
2138    let template = ScanSetupTemplate {
2139        version: env!("CARGO_PKG_VERSION"),
2140        recent_scans_json,
2141        csp_nonce,
2142    };
2143    Html(
2144        template
2145            .render()
2146            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2147    )
2148}
2149
2150async fn healthz() -> &'static str {
2151    "ok"
2152}
2153
2154async fn api_version_handler() -> impl IntoResponse {
2155    axum::Json(serde_json::json!({
2156        "name": "oxide-sloc",
2157        "version": env!("CARGO_PKG_VERSION"),
2158    }))
2159}
2160
2161// ── Prometheus metrics ────────────────────────────────────────────────────────
2162
2163fn prom_runs_total() -> &'static prometheus::IntCounter {
2164    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2165    COUNTER.get_or_init(|| {
2166        prometheus::register_int_counter!(
2167            "oxide_sloc_runs_total",
2168            "Total number of completed analysis runs"
2169        )
2170        .expect("failed to register oxide_sloc_runs_total counter")
2171    })
2172}
2173
2174async fn metrics_handler() -> impl IntoResponse {
2175    use prometheus::Encoder as _;
2176    let mut buf = Vec::new();
2177    let encoder = prometheus::TextEncoder::new();
2178    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2179    (
2180        [(
2181            axum::http::header::CONTENT_TYPE,
2182            "text/plain; version=0.0.4; charset=utf-8",
2183        )],
2184        buf,
2185    )
2186}
2187
2188static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2189
2190async fn openapi_yaml_handler() -> impl IntoResponse {
2191    (
2192        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2193        OPENAPI_YAML,
2194    )
2195}
2196
2197static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2198static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2199
2200async fn llms_txt_handler() -> impl IntoResponse {
2201    (
2202        [
2203            (
2204                axum::http::header::CONTENT_TYPE,
2205                "text/plain; charset=utf-8",
2206            ),
2207            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2208        ],
2209        LLMS_TXT,
2210    )
2211}
2212
2213async fn llms_full_txt_handler() -> impl IntoResponse {
2214    (
2215        [
2216            (
2217                axum::http::header::CONTENT_TYPE,
2218                "text/plain; charset=utf-8",
2219            ),
2220            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2221        ],
2222        LLMS_FULL_TXT,
2223    )
2224}
2225
2226async fn api_docs_handler(
2227    State(state): State<AppState>,
2228    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2229) -> impl IntoResponse {
2230    let has_api_key = !state.api_keys.is_empty();
2231    Html(
2232        ApiDocsTemplate {
2233            has_api_key,
2234            csp_nonce,
2235            version: env!("CARGO_PKG_VERSION"),
2236        }
2237        .render()
2238        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2239    )
2240}
2241
2242async fn chart_js_handler() -> impl IntoResponse {
2243    (
2244        [
2245            (
2246                header::CONTENT_TYPE,
2247                "application/javascript; charset=utf-8",
2248            ),
2249            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2250        ],
2251        CHART_JS,
2252    )
2253}
2254
2255async fn report_chart_js_handler() -> impl IntoResponse {
2256    (
2257        [
2258            (
2259                header::CONTENT_TYPE,
2260                "application/javascript; charset=utf-8",
2261            ),
2262            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2263        ],
2264        REPORT_CHART_JS,
2265    )
2266}
2267
2268#[derive(Debug, Deserialize)]
2269struct AnalyzeForm {
2270    path: String,
2271    git_repo: Option<String>,
2272    git_ref: Option<String>,
2273    mixed_line_policy: Option<MixedLinePolicy>,
2274    python_docstrings_as_comments: Option<String>,
2275    generated_file_detection: Option<String>,
2276    minified_file_detection: Option<String>,
2277    vendor_directory_detection: Option<String>,
2278    include_lockfiles: Option<String>,
2279    binary_file_behavior: Option<BinaryFileBehavior>,
2280    output_dir: Option<String>,
2281    report_title: Option<String>,
2282    report_header_footer: Option<String>,
2283    include_globs: Option<String>,
2284    exclude_globs: Option<String>,
2285    submodule_breakdown: Option<String>,
2286    coverage_file: Option<String>,
2287    continuation_line_policy: Option<ContinuationLinePolicy>,
2288    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2289    count_compiler_directives: Option<String>,
2290    style_col_threshold: Option<String>,
2291    style_analysis_enabled: Option<String>,
2292    style_score_threshold: Option<String>,
2293    style_lang_scope: Option<String>,
2294    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2295    cocomo_mode: Option<String>,
2296    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2297    complexity_alert: Option<String>,
2298    /// Whether to exclude duplicate files from displayed SLOC totals.
2299    exclude_duplicates: Option<String>,
2300    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2301    activity_window: Option<String>,
2302}
2303
2304#[allow(clippy::struct_excessive_bools)]
2305#[derive(Debug, Serialize, Deserialize, Clone)]
2306struct ScanConfig {
2307    oxide_sloc_version: String,
2308    path: String,
2309    include_globs: String,
2310    exclude_globs: String,
2311    submodule_breakdown: bool,
2312    mixed_line_policy: String,
2313    python_docstrings_as_comments: bool,
2314    generated_file_detection: bool,
2315    minified_file_detection: bool,
2316    vendor_directory_detection: bool,
2317    include_lockfiles: bool,
2318    binary_file_behavior: String,
2319    output_dir: String,
2320    report_title: String,
2321    // IEEE 1045-1992 and advanced fields added in later release
2322    #[serde(default = "default_each_physical_line")]
2323    continuation_line_policy: String,
2324    #[serde(default = "default_count_as_comment")]
2325    blank_in_block_comment_policy: String,
2326    #[serde(default = "default_true_bool")]
2327    count_compiler_directives: bool,
2328    #[serde(default = "default_true_bool")]
2329    style_analysis_enabled: bool,
2330    #[serde(default = "default_style_col_threshold")]
2331    style_col_threshold: u16,
2332    #[serde(default)]
2333    style_score_threshold: u8,
2334    #[serde(default = "default_all_scope")]
2335    style_lang_scope: String,
2336    #[serde(default)]
2337    coverage_file: String,
2338    #[serde(default = "default_organic")]
2339    cocomo_mode: String,
2340    #[serde(default)]
2341    complexity_alert: u32,
2342    #[serde(default)]
2343    exclude_duplicates: bool,
2344    /// Git hotspots activity window in days (on by default; 0 = disabled).
2345    #[serde(default = "default_activity_window")]
2346    activity_window: u32,
2347}
2348
2349const fn default_activity_window() -> u32 {
2350    90
2351}
2352
2353fn default_each_physical_line() -> String {
2354    "each_physical_line".to_string()
2355}
2356fn default_count_as_comment() -> String {
2357    "count_as_comment".to_string()
2358}
2359const fn default_true_bool() -> bool {
2360    true
2361}
2362const fn default_style_col_threshold() -> u16 {
2363    80
2364}
2365fn default_all_scope() -> String {
2366    "all".to_string()
2367}
2368fn default_organic() -> String {
2369    "organic".to_string()
2370}
2371
2372#[derive(Debug, Deserialize, Default)]
2373struct IndexQuery {
2374    path: Option<String>,
2375    include_globs: Option<String>,
2376    exclude_globs: Option<String>,
2377    submodule_breakdown: Option<String>,
2378    mixed_line_policy: Option<String>,
2379    python_docstrings_as_comments: Option<String>,
2380    generated_file_detection: Option<String>,
2381    minified_file_detection: Option<String>,
2382    vendor_directory_detection: Option<String>,
2383    include_lockfiles: Option<String>,
2384    binary_file_behavior: Option<String>,
2385    output_dir: Option<String>,
2386    report_title: Option<String>,
2387    prefilled: Option<String>,
2388    git_repo: Option<String>,
2389    git_ref: Option<String>,
2390    // IEEE 1045-1992 and advanced fields
2391    continuation_line_policy: Option<String>,
2392    blank_in_block_comment_policy: Option<String>,
2393    count_compiler_directives: Option<String>,
2394    style_analysis_enabled: Option<String>,
2395    style_col_threshold: Option<String>,
2396    style_score_threshold: Option<String>,
2397    style_lang_scope: Option<String>,
2398    coverage_file: Option<String>,
2399    cocomo_mode: Option<String>,
2400    complexity_alert: Option<String>,
2401    exclude_duplicates: Option<String>,
2402    activity_window: Option<String>,
2403}
2404
2405#[derive(Debug, Deserialize)]
2406struct PreviewQuery {
2407    path: Option<String>,
2408    include_globs: Option<String>,
2409    exclude_globs: Option<String>,
2410}
2411
2412#[cfg(feature = "native-dialog")]
2413#[derive(Debug, Deserialize)]
2414struct PickDirectoryQuery {
2415    kind: Option<String>,
2416    current: Option<String>,
2417}
2418
2419#[cfg(not(feature = "native-dialog"))]
2420#[derive(Debug, Deserialize)]
2421struct PickDirectoryQuery {}
2422
2423#[derive(Debug, Deserialize, Default)]
2424struct ArtifactQuery {
2425    download: Option<String>,
2426}
2427
2428#[cfg(feature = "native-dialog")]
2429#[derive(Debug, Serialize)]
2430struct PickDirectoryResponse {
2431    selected_path: Option<String>,
2432    cancelled: bool,
2433}
2434
2435#[cfg(feature = "native-dialog")]
2436async fn pick_directory_handler(
2437    State(state): State<AppState>,
2438    Query(query): Query<PickDirectoryQuery>,
2439) -> Response {
2440    if state.server_mode {
2441        return StatusCode::NOT_FOUND.into_response();
2442    }
2443    // Return immediately without opening a dialog in headless / CI environments.
2444    if std::env::var("SLOC_HEADLESS").is_ok() {
2445        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2446            .into_response();
2447    }
2448
2449    let is_coverage = query.kind.as_deref() == Some("coverage");
2450    let title = match query.kind.as_deref() {
2451        Some("output") => "Select output directory",
2452        Some("reports") => "Select folder containing saved reports",
2453        Some("coverage") => "Select LCOV coverage file",
2454        _ => "Select project directory",
2455    }
2456    .to_owned();
2457    let current = query.current.clone();
2458
2459    let picked = tokio::task::spawn_blocking(move || {
2460        // Windows: attach to the foreground thread so the dialog inherits focus,
2461        // and kick off a watcher that flashes the dialog once it appears.
2462        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2463        let fg_tid = win_dialog_focus::attach_to_foreground();
2464        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2465        win_dialog_focus::flash_dialog_when_ready(title.clone());
2466
2467        let mut dialog = rfd::FileDialog::new().set_title(&title);
2468        if let Some(current) = current.as_deref() {
2469            let resolved = resolve_input_path(current);
2470            let seed = if resolved.is_dir() {
2471                Some(resolved)
2472            } else {
2473                resolved.parent().map(Path::to_path_buf)
2474            };
2475            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2476                dialog = dialog.set_directory(seed_dir);
2477            }
2478        }
2479        let result = if is_coverage {
2480            dialog
2481                .add_filter(
2482                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2483                    &["info", "lcov", "xml", "json"],
2484                )
2485                .pick_file()
2486        } else {
2487            dialog.pick_folder()
2488        };
2489
2490        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2491        win_dialog_focus::detach_from_foreground(fg_tid);
2492
2493        result
2494    })
2495    .await
2496    .unwrap_or(None);
2497
2498    Json(PickDirectoryResponse {
2499        selected_path: picked.as_ref().map(|p| display_path(p)),
2500        cancelled: picked.is_none(),
2501    })
2502    .into_response()
2503}
2504
2505#[cfg(not(feature = "native-dialog"))]
2506async fn pick_directory_handler(
2507    State(_state): State<AppState>,
2508    Query(_query): Query<PickDirectoryQuery>,
2509) -> Response {
2510    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2511}
2512
2513#[cfg(feature = "native-dialog")]
2514async fn pick_file_handler(State(state): State<AppState>) -> Response {
2515    if state.server_mode {
2516        return StatusCode::NOT_FOUND.into_response();
2517    }
2518    if std::env::var("SLOC_HEADLESS").is_ok() {
2519        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2520            .into_response();
2521    }
2522    let picked = tokio::task::spawn_blocking(|| {
2523        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2524        let fg_tid = win_dialog_focus::attach_to_foreground();
2525        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2526        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2527
2528        let result = rfd::FileDialog::new()
2529            .set_title("Select HTML report")
2530            .add_filter("HTML report", &["html"])
2531            .pick_file();
2532
2533        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2534        win_dialog_focus::detach_from_foreground(fg_tid);
2535
2536        result
2537    })
2538    .await
2539    .unwrap_or(None);
2540    Json(PickDirectoryResponse {
2541        selected_path: picked.as_ref().map(|p| display_path(p)),
2542        cancelled: picked.is_none(),
2543    })
2544    .into_response()
2545}
2546
2547#[cfg(not(feature = "native-dialog"))]
2548async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2549    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2550}
2551
2552// ── Browser-upload handlers (server mode only) ────────────────────────────────
2553
2554/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2555/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2556fn is_upload_tmp_path(path: &Path) -> bool {
2557    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2558    path.starts_with(&upload_root)
2559}
2560
2561/// Returns true when `path` is the built-in sample or test-fixture directory.
2562/// These paths ship with the server binary and are always safe to scan/preview.
2563fn is_sample_path(path: &Path) -> bool {
2564    let root = workspace_root();
2565    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2566}
2567
2568/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2569fn upload_base_dir() -> PathBuf {
2570    std::env::temp_dir().join("oxide-sloc-uploads")
2571}
2572
2573/// Returns the staging path for a given upload id inside the base dir.
2574fn upload_staging_path(id: &str) -> PathBuf {
2575    upload_base_dir().join(id)
2576}
2577
2578/// Validate basic field constraints on a directory-upload request.
2579/// Returns an error `Response` if the request should be rejected immediately.
2580#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2581fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2582    const MAX_FILES: usize = 50_000;
2583    if body.files.is_empty() {
2584        return Err((
2585            StatusCode::BAD_REQUEST,
2586            Json(serde_json::json!({"error": "No files received"})),
2587        )
2588            .into_response());
2589    }
2590    if body.files.len() > MAX_FILES {
2591        return Err((
2592            StatusCode::PAYLOAD_TOO_LARGE,
2593            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2594        )
2595            .into_response());
2596    }
2597    Ok(())
2598}
2599
2600/// Resolve or create the staging directory for a directory upload.
2601/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2602fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2603    match id {
2604        Some(id)
2605            if !id.is_empty()
2606                && id.len() <= 36
2607                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2608        {
2609            (id.to_string(), upload_staging_path(id))
2610        }
2611        _ => {
2612            let new_id = uuid::Uuid::new_v4().to_string();
2613            let staging = upload_staging_path(&new_id);
2614            (new_id, staging)
2615        }
2616    }
2617}
2618
2619/// Decode, size-check, and write one uploaded file entry into `staging`.
2620/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2621/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2622/// cleaning up `staging` before propagating the error.
2623#[allow(clippy::result_large_err)]
2624async fn stage_decoded_entry(
2625    entry: &UploadedFile,
2626    staging: &Path,
2627    total_bytes: &mut usize,
2628    project_root: &mut Option<PathBuf>,
2629) -> Result<(), Response> {
2630    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2631
2632    let Ok(data) = base64::Engine::decode(
2633        &base64::engine::general_purpose::STANDARD,
2634        entry.content.as_bytes(),
2635    ) else {
2636        return Ok(());
2637    };
2638
2639    *total_bytes += data.len();
2640    if *total_bytes > MAX_TOTAL_BYTES {
2641        return Err((
2642            StatusCode::PAYLOAD_TOO_LARGE,
2643            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2644        )
2645            .into_response());
2646    }
2647
2648    let rel = std::path::Path::new(&entry.path);
2649    if project_root.is_none() {
2650        if let Some(first) = rel.components().next() {
2651            *project_root = Some(staging.join(first.as_os_str()));
2652        }
2653    }
2654
2655    let dest = staging.join(rel);
2656    if let Some(parent) = dest.parent() {
2657        if tokio::fs::create_dir_all(parent).await.is_err() {
2658            return Err((
2659                StatusCode::INTERNAL_SERVER_ERROR,
2660                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2661            )
2662                .into_response());
2663        }
2664    }
2665
2666    if tokio::fs::write(&dest, &data).await.is_err() {
2667        return Err((
2668            StatusCode::INTERNAL_SERVER_ERROR,
2669            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2670        )
2671            .into_response());
2672    }
2673
2674    Ok(())
2675}
2676
2677/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2678/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2679/// an error `Response` on failure (staging dir is cleaned up before returning).
2680async fn write_upload_files(
2681    files: &[UploadedFile],
2682    staging: &Path,
2683    upload_id: &str,
2684) -> Result<(usize, Option<PathBuf>), Response> {
2685    let mut total_bytes: usize = 0;
2686    let mut project_root: Option<PathBuf> = None;
2687
2688    for entry in files {
2689        let rel = std::path::Path::new(&entry.path);
2690        if rel
2691            .components()
2692            .any(|c| matches!(c, std::path::Component::ParentDir))
2693        {
2694            // Reject the entire upload on the first path traversal attempt.
2695            let _ = tokio::fs::remove_dir_all(staging).await;
2696            tracing::warn!(
2697                event = "upload_path_traversal",
2698                upload_id = %upload_id,
2699                path = %entry.path,
2700                "Upload rejected: path traversal component detected"
2701            );
2702            return Err((
2703                StatusCode::BAD_REQUEST,
2704                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2705            )
2706                .into_response());
2707        }
2708
2709        if let Err(resp) =
2710            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2711        {
2712            let _ = tokio::fs::remove_dir_all(staging).await;
2713            return Err(resp);
2714        }
2715    }
2716
2717    Ok((files.len(), project_root))
2718}
2719
2720/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2721/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2722fn parse_tarball_size_caps() -> (u64, u64) {
2723    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2724        .ok()
2725        .and_then(|v| v.parse().ok())
2726        .unwrap_or(2048_u64)
2727        * 1024
2728        * 1024;
2729    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2730        .ok()
2731        .and_then(|v| v.parse().ok())
2732        .unwrap_or(10_240_u64)
2733        * 1024
2734        * 1024;
2735    (compressed, decompressed)
2736}
2737
2738/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
2739/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
2740/// are rejected before the streaming handler is invoked.
2741fn tarball_http_body_limit_bytes() -> usize {
2742    std::env::var("SLOC_MAX_TARBALL_MB")
2743        .ok()
2744        .and_then(|v| v.parse::<usize>().ok())
2745        .unwrap_or(2048)
2746        .saturating_mul(1024 * 1024)
2747}
2748
2749/// Stream `body` into `dest_path`, enforcing `max_bytes`.
2750/// Returns the number of compressed bytes written, or an error `Response`.
2751/// Cleans up `dest_path` on error.
2752#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2753async fn stream_body_to_file(
2754    body: axum::body::Body,
2755    dest_path: &Path,
2756    max_bytes: u64,
2757) -> Result<u64, Response> {
2758    use http_body_util::BodyExt as _;
2759    use tokio::io::AsyncWriteExt as _;
2760
2761    let mut file = match tokio::fs::File::create(dest_path).await {
2762        Ok(f) => f,
2763        Err(e) => {
2764            tracing::error!(
2765                event = "upload_io_error",
2766                "failed to create tarball temp file: {e}"
2767            );
2768            return Err((
2769                StatusCode::INTERNAL_SERVER_ERROR,
2770                Json(serde_json::json!({"error": "Upload initialization failed"})),
2771            )
2772                .into_response());
2773        }
2774    };
2775
2776    let mut body = body;
2777    let mut written: u64 = 0;
2778    loop {
2779        match body.frame().await {
2780            None => break,
2781            Some(Err(e)) => {
2782                let _ = tokio::fs::remove_file(dest_path).await;
2783                return Err((
2784                    StatusCode::BAD_REQUEST,
2785                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
2786                )
2787                    .into_response());
2788            }
2789            Some(Ok(frame)) => {
2790                if let Ok(data) = frame.into_data() {
2791                    written += data.len() as u64;
2792                    if written > max_bytes {
2793                        let _ = tokio::fs::remove_file(dest_path).await;
2794                        return Err((
2795                            StatusCode::PAYLOAD_TOO_LARGE,
2796                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
2797                        )
2798                            .into_response());
2799                    }
2800                    if let Err(e) = file.write_all(&data).await {
2801                        let _ = tokio::fs::remove_file(dest_path).await;
2802                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
2803                        return Err((
2804                            StatusCode::INTERNAL_SERVER_ERROR,
2805                            Json(serde_json::json!({"error": "Upload write failed"})),
2806                        )
2807                            .into_response());
2808                    }
2809                }
2810            }
2811        }
2812    }
2813    drop(file);
2814    Ok(written)
2815}
2816
2817/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
2818/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
2819/// on failure (staging dir is cleaned up before returning).
2820#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2821async fn extract_tarball_to_staging(
2822    tarball_path: &Path,
2823    staging: &Path,
2824    max_decompressed_bytes: u64,
2825) -> Result<(), Response> {
2826    let staging_clone = staging.to_path_buf();
2827    let tarball_clone = tarball_path.to_path_buf();
2828    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
2829        let file = std::fs::File::open(&tarball_clone)?;
2830        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
2831        let limited = SizeLimitReader {
2832            inner: gz,
2833            remaining: max_decompressed_bytes,
2834        };
2835        let mut archive = tar::Archive::new(limited);
2836        archive.set_overwrite(true);
2837        archive.set_preserve_permissions(false);
2838        std::fs::create_dir_all(&staging_clone)?;
2839        archive.unpack(&staging_clone)?;
2840        Ok(())
2841    })
2842    .await;
2843    let _ = tokio::fs::remove_file(tarball_path).await;
2844
2845    match extract_result {
2846        Ok(Ok(())) => Ok(()),
2847        Ok(Err(e)) => {
2848            let _ = tokio::fs::remove_dir_all(staging).await;
2849            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
2850            tracing::warn!(
2851                event = "upload_extract_error",
2852                "tarball extraction failed: {e:#}"
2853            );
2854            let (status, msg) = if is_size_limit {
2855                (
2856                    StatusCode::PAYLOAD_TOO_LARGE,
2857                    "Archive exceeds the decompressed size limit",
2858                )
2859            } else {
2860                (StatusCode::BAD_REQUEST, "Failed to extract archive")
2861            };
2862            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
2863        }
2864        Err(e) => {
2865            let _ = tokio::fs::remove_dir_all(staging).await;
2866            tracing::error!(
2867                event = "upload_extract_panic",
2868                "tarball extraction task panicked: {e}"
2869            );
2870            Err((
2871                StatusCode::INTERNAL_SERVER_ERROR,
2872                Json(serde_json::json!({"error": "Archive extraction failed"})),
2873            )
2874                .into_response())
2875        }
2876    }
2877}
2878
2879/// If `staging` contains exactly one top-level directory, return its path
2880/// (the common case when the archive was created with `webkitRelativePath`).
2881/// Otherwise return `None`.
2882async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
2883    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
2884    let first = entries.next_entry().await.ok()??;
2885    if !first.path().is_dir() {
2886        return None;
2887    }
2888    if entries.next_entry().await.unwrap_or(None).is_some() {
2889        return None;
2890    }
2891    Some(first.path())
2892}
2893
2894/// Request body for `POST /api/upload-directory`.
2895///
2896/// Each entry carries a relative path (identical to the browser's
2897/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
2898/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
2899/// avoids pulling in a `multipart` library that is not in the vendor archive.
2900#[derive(Deserialize)]
2901struct UploadDirRequest {
2902    files: Vec<UploadedFile>,
2903    /// If provided, append this batch to an existing upload session instead of
2904    /// creating a new staging directory. Must be a plain UUID (no path separators).
2905    upload_id: Option<String>,
2906}
2907
2908#[derive(Deserialize)]
2909struct UploadedFile {
2910    /// `webkitRelativePath` value from the browser File object.
2911    path: String,
2912    /// Raw file bytes encoded as standard base64.
2913    content: String,
2914}
2915
2916/// POST /api/upload-directory
2917///
2918/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
2919/// Saves all files to a temp staging directory preserving their relative paths,
2920/// then returns the server-side root directory path so the caller can populate
2921/// the scan-path field and run a normal analysis.
2922///
2923/// Only available in server mode; returns 404 in local mode (use the native
2924/// rfd dialog instead).
2925async fn upload_directory_handler(
2926    State(state): State<AppState>,
2927    Json(body): Json<UploadDirRequest>,
2928) -> Response {
2929    if !state.server_mode {
2930        return StatusCode::NOT_FOUND.into_response();
2931    }
2932    if let Err(resp) = validate_upload_dir_request(&body) {
2933        return resp;
2934    }
2935    // Reuse an existing staging dir when the client sends a continuation batch,
2936    // otherwise create a fresh one. Validate the id to prevent path traversal.
2937    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
2938    match write_upload_files(&body.files, &staging, &upload_id).await {
2939        Ok((file_count, project_root)) => {
2940            let scan_root = project_root.unwrap_or_else(|| staging.clone());
2941            Json(serde_json::json!({
2942                "tmp_path": scan_root.to_string_lossy(),
2943                "file_count": file_count,
2944                "upload_id": upload_id.clone()
2945            }))
2946            .into_response()
2947        }
2948        Err(resp) => resp,
2949    }
2950}
2951
2952/// Request body for `POST /api/upload-file`.
2953#[derive(Deserialize)]
2954struct UploadFileRequest {
2955    /// Original filename (used only to preserve the extension).
2956    filename: String,
2957    /// File bytes encoded as standard base64.
2958    content: String,
2959}
2960
2961/// POST /api/upload-file
2962///
2963/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
2964/// Accepts `{ "filename": "…", "content": "<base64>" }`.
2965/// Only available in server mode.
2966async fn upload_file_handler(
2967    State(state): State<AppState>,
2968    Json(body): Json<UploadFileRequest>,
2969) -> Response {
2970    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
2971
2972    if !state.server_mode {
2973        return StatusCode::NOT_FOUND.into_response();
2974    }
2975
2976    let Ok(data) = base64::Engine::decode(
2977        &base64::engine::general_purpose::STANDARD,
2978        body.content.as_bytes(),
2979    ) else {
2980        return (
2981            StatusCode::BAD_REQUEST,
2982            Json(serde_json::json!({"error": "Invalid base64 content"})),
2983        )
2984            .into_response();
2985    };
2986
2987    if data.len() > MAX_FILE_BYTES {
2988        return (
2989            StatusCode::PAYLOAD_TOO_LARGE,
2990            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
2991        )
2992            .into_response();
2993    }
2994
2995    // Sanitise: strip any directory component from the filename.
2996    let filename = std::path::Path::new(&body.filename)
2997        .file_name()
2998        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
2999
3000    let upload_id = uuid::Uuid::new_v4();
3001    let staging = std::env::temp_dir()
3002        .join("oxide-sloc-uploads")
3003        .join(upload_id.to_string());
3004
3005    if tokio::fs::create_dir_all(&staging).await.is_err() {
3006        return (
3007            StatusCode::INTERNAL_SERVER_ERROR,
3008            Json(serde_json::json!({"error": "Failed to create staging directory"})),
3009        )
3010            .into_response();
3011    }
3012
3013    let dest = staging.join(&filename);
3014    if tokio::fs::write(&dest, &data).await.is_err() {
3015        let _ = tokio::fs::remove_dir_all(&staging).await;
3016        return (
3017            StatusCode::INTERNAL_SERVER_ERROR,
3018            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3019        )
3020            .into_response();
3021    }
3022
3023    Json(serde_json::json!({
3024        "tmp_path": dest.to_string_lossy(),
3025        "upload_id": upload_id.to_string()
3026    }))
3027    .into_response()
3028}
3029
3030/// POST /api/upload-tarball
3031///
3032/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
3033/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
3034/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
3035/// project root. The two size fields power the "Original / Compressed project size" display in the
3036/// web UI.
3037///
3038/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
3039/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
3040/// cap during decompression. The browser-side JS creates the archive one file at a time using
3041/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
3042/// project size.
3043/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
3044/// decompressed. Wraps any `std::io::Read` source.
3045struct SizeLimitReader<R> {
3046    inner: R,
3047    remaining: u64,
3048}
3049impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3050    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3051        if self.remaining == 0 {
3052            return Err(std::io::Error::other("decompressed size limit exceeded"));
3053        }
3054        let n = self.inner.read(buf)?;
3055        self.remaining = self.remaining.saturating_sub(n as u64);
3056        Ok(n)
3057    }
3058}
3059
3060async fn upload_tarball_handler(
3061    State(state): State<AppState>,
3062    request: axum::extract::Request,
3063) -> Response {
3064    if !state.server_mode {
3065        return StatusCode::NOT_FOUND.into_response();
3066    }
3067
3068    let upload_id = uuid::Uuid::new_v4().to_string();
3069    let upload_base = upload_base_dir();
3070    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3071    let staging = upload_staging_path(&upload_id);
3072    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3073
3074    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3075        tracing::error!(
3076            event = "upload_io_error",
3077            "failed to create upload base dir: {e}"
3078        );
3079        return (
3080            StatusCode::INTERNAL_SERVER_ERROR,
3081            Json(serde_json::json!({"error": "Upload initialization failed"})),
3082        )
3083            .into_response();
3084    }
3085
3086    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
3087    let compressed_bytes =
3088        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3089            Ok(n) => n,
3090            Err(resp) => return resp,
3091        };
3092
3093    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
3094    if let Err(resp) =
3095        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3096    {
3097        return resp;
3098    }
3099
3100    // ── 3. Find the project root inside the staging dir ───────────────────────
3101    // If the tar contained a single top-level directory (the common case when the
3102    // browser uses `webkitRelativePath`), return that as the scan root so the path
3103    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3104    let scan_root = find_single_top_dir(&staging)
3105        .await
3106        .unwrap_or_else(|| staging.clone());
3107
3108    // Compute original (uncompressed) size of the extracted tree.
3109    let original_bytes = tokio::task::spawn_blocking({
3110        let p = scan_root.clone();
3111        move || dir_size_bytes(&p)
3112    })
3113    .await
3114    .unwrap_or(0);
3115
3116    Json(serde_json::json!({
3117        "tmp_path": scan_root.to_string_lossy(),
3118        "upload_id": upload_id,
3119        "compressed_bytes": compressed_bytes,
3120        "original_bytes": original_bytes,
3121    }))
3122    .into_response()
3123}
3124
3125#[derive(Deserialize)]
3126struct LocateReportForm {
3127    file_path: String,
3128    #[serde(default)]
3129    redirect_url: Option<String>,
3130    #[serde(default)]
3131    expected_run_id: Option<String>,
3132}
3133
3134/// Render a view-reports error page and return it as a `Response`.
3135fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3136    let html = ErrorTemplate {
3137        message: message.into(),
3138        last_report_url: Some("/view-reports".to_string()),
3139        last_report_label: Some("View Reports".to_string()),
3140        run_id: None,
3141        error_code: None,
3142        csp_nonce: csp_nonce.to_owned(),
3143        version: env!("CARGO_PKG_VERSION"),
3144    }
3145    .render()
3146    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3147    Html(html).into_response()
3148}
3149
3150/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3151fn registry_entry_from_run(
3152    run: &AnalysisRun,
3153    json_path: PathBuf,
3154    html_path: PathBuf,
3155) -> RegistryEntry {
3156    let project_label = run.input_roots.first().map_or_else(
3157        || "Unknown Project".to_string(),
3158        |r| sanitize_project_label(r),
3159    );
3160    RegistryEntry {
3161        run_id: run.tool.run_id.clone(),
3162        timestamp_utc: run.tool.timestamp_utc,
3163        project_label,
3164        input_roots: run.input_roots.clone(),
3165        json_path: Some(json_path),
3166        html_path: Some(html_path),
3167        pdf_path: None,
3168        summary: ScanSummarySnapshot::from(&run.summary_totals),
3169        csv_path: None,
3170        xlsx_path: None,
3171        git_branch: None,
3172        git_commit: None,
3173        git_commit_long: None,
3174        git_author: None,
3175        git_tags: None,
3176        git_nearest_tag: None,
3177        git_commit_date: None,
3178    }
3179}
3180
3181/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3182/// immediately without requiring a server restart.
3183pub(crate) async fn register_artifacts_in_registry(
3184    state: &AppState,
3185    label: &str,
3186    run: &AnalysisRun,
3187    artifacts: &RunArtifacts,
3188) {
3189    let Some(json_path) = artifacts.json_path.clone() else {
3190        return;
3191    };
3192    let Some(html_path) = artifacts.html_path.clone() else {
3193        return;
3194    };
3195    let mut entry = registry_entry_from_run(run, json_path, html_path);
3196    entry.project_label = label.to_owned();
3197    let mut reg = state.registry.lock().await;
3198    reg.add_entry(entry);
3199    let _ = reg.save(&state.registry_path);
3200}
3201
3202fn is_html_report_file(p: &Path) -> bool {
3203    p.is_file()
3204        && p.extension()
3205            .and_then(|x| x.to_str())
3206            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3207        && p.file_name()
3208            .and_then(|n| n.to_str())
3209            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3210}
3211
3212fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3213    fs::read_dir(dir)
3214        .ok()?
3215        .flatten()
3216        .map(|e| e.path())
3217        .find(|p| is_html_report_file(p))
3218}
3219
3220fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3221    if let Some(f) = find_html_report_in_dir(dir) {
3222        return Some(f);
3223    }
3224    if let Ok(rd) = fs::read_dir(dir) {
3225        for entry in rd.flatten() {
3226            let sub = entry.path();
3227            if sub.is_dir() {
3228                if let Some(f) = find_html_report_in_dir(&sub) {
3229                    return Some(f);
3230                }
3231            }
3232        }
3233    }
3234    None
3235}
3236
3237/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3238/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3239///
3240/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3241#[allow(clippy::result_large_err)]
3242fn validate_locate_request(
3243    state: &AppState,
3244    file_path: &str,
3245    csp_nonce: &str,
3246) -> Result<(PathBuf, PathBuf), Response> {
3247    let raw = PathBuf::from(file_path);
3248
3249    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3250    let html_path = if raw.is_dir() {
3251        let found = find_html_report_in_tree(&raw);
3252        match found {
3253            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3254            None => {
3255                return Err(locate_report_error(
3256                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3257                     the folder that contains your scan output (result_*.html or report_*.html).",
3258                    csp_nonce,
3259                ));
3260            }
3261        }
3262    } else {
3263        let file_ext = raw
3264            .extension()
3265            .and_then(|e| e.to_str())
3266            .unwrap_or("")
3267            .to_ascii_lowercase();
3268        if file_ext != "html" {
3269            return Err(locate_report_error(
3270                "Please select the scan output folder, or an .html report file directly.",
3271                csp_nonce,
3272            ));
3273        }
3274        match fs::canonicalize(&raw) {
3275            Ok(p) => strip_unc_prefix(p),
3276            Err(_) => {
3277                return Err(locate_report_error(
3278                    "Report file not found or path is invalid.",
3279                    csp_nonce,
3280                ));
3281            }
3282        }
3283    };
3284
3285    if state.server_mode {
3286        let output_root = resolve_output_root(None);
3287        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3288        if !html_path.starts_with(&canonical_root) {
3289            return Err(locate_report_error(
3290                "Report file must be within the configured output directory.",
3291                csp_nonce,
3292            ));
3293        }
3294    }
3295    let parent = match html_path.parent() {
3296        Some(p) => p.to_path_buf(),
3297        None => {
3298            return Err(locate_report_error(
3299                "Report file has no parent directory.",
3300                csp_nonce,
3301            ));
3302        }
3303    };
3304    Ok((html_path, parent))
3305}
3306
3307/// JSON-or-HTML error for `locate_report_handler` error paths.
3308fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3309    if want_json {
3310        (
3311            StatusCode::UNPROCESSABLE_ENTITY,
3312            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3313        )
3314            .into_response()
3315    } else {
3316        locate_report_error(msg, csp_nonce)
3317    }
3318}
3319
3320/// JSON-or-redirect success for locate/relocate handler success paths.
3321fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3322    if want_json {
3323        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3324    } else {
3325        axum::response::Redirect::to(redirect).into_response()
3326    }
3327}
3328
3329/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3330/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3331fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3332    for jpath in candidates {
3333        if let Ok(run) = read_json(jpath) {
3334            if expected.is_empty() || run.tool.run_id == expected {
3335                return Some((jpath.clone(), run.tool.run_id));
3336            }
3337        }
3338    }
3339    None
3340}
3341
3342fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3343    html_path
3344        .parent()
3345        .and_then(|p| p.parent())
3346        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3347}
3348
3349fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3350    let mut hits = collect_result_json_candidates(scan_root);
3351    if hits.is_empty() {
3352        hits = collect_result_json_candidates(parent);
3353    }
3354    hits.sort();
3355    hits
3356}
3357
3358#[allow(clippy::too_many_lines)]
3359async fn locate_report_handler(
3360    State(state): State<AppState>,
3361    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3362    headers: axum::http::HeaderMap,
3363    Form(form): Form<LocateReportForm>,
3364) -> impl IntoResponse {
3365    let want_json = headers
3366        .get(axum::http::header::ACCEPT)
3367        .and_then(|v| v.to_str().ok())
3368        .is_some_and(|v| v.contains("application/json"));
3369
3370    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3371        Ok(v) => v,
3372        Err(resp) => {
3373            if want_json {
3374                return locate_handler_err(
3375                    true,
3376                    "No HTML report file found in the selected folder. \
3377                     Make sure you selected the folder that contains your \
3378                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3379                        .to_string(),
3380                    &csp_nonce,
3381                );
3382            }
3383            return resp;
3384        }
3385    };
3386
3387    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3388    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3389    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3390    let scan_root: &Path = &scan_root_owned;
3391    let json_candidates = gather_json_candidates(scan_root, &parent);
3392
3393    // If the expected_run_id was provided, find a JSON that matches it exactly.
3394    let expected_run_id = form
3395        .expected_run_id
3396        .as_deref()
3397        .unwrap_or("")
3398        .trim()
3399        .to_string();
3400
3401    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3402
3403    // If we have candidates but none matched the expected run_id, surface a clear error.
3404    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3405        let actual = json_candidates
3406            .iter()
3407            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3408            .unwrap_or_else(|| "unknown".to_string());
3409        return locate_handler_err(
3410            want_json,
3411            format!(
3412                "This folder contains a different scan.\n\n\
3413                 Expected run ID : {expected_run_id}\n\
3414                 Found run ID    : {actual}\n\n\
3415                 Please select the folder that contains the correct scan output."
3416            ),
3417            &csp_nonce,
3418        );
3419    }
3420
3421    let safe_redirect = form
3422        .redirect_url
3423        .as_deref()
3424        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3425        .unwrap_or("/view-reports?linked=1")
3426        .to_string();
3427
3428    let mut reg = state.registry.lock().await;
3429
3430    if let Some((json_path, run_id)) = matched_json {
3431        // Match by run_id in the registry (works even after files are moved).
3432        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3433            entry.html_path = Some(html_path);
3434            entry.json_path = Some(json_path);
3435            let _ = reg.save(&state.registry_path);
3436            drop(reg);
3437            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3438            state.artifacts.lock().await.remove(&run_id);
3439            return redirect_or_json_ok(want_json, &safe_redirect);
3440        }
3441        // No existing entry — build one from the JSON.
3442        match read_json(&json_path) {
3443            Ok(run) => {
3444                let entry = registry_entry_from_run(&run, json_path, html_path);
3445                reg.add_entry(entry);
3446                let _ = reg.save(&state.registry_path);
3447                drop(reg);
3448                state.artifacts.lock().await.remove(&run_id);
3449                return redirect_or_json_ok(want_json, &safe_redirect);
3450            }
3451            Err(e) => {
3452                drop(reg);
3453                return locate_handler_err(
3454                    want_json,
3455                    format!(
3456                        "Found the scan folder but could not parse the result JSON.\n\n\
3457                         The file may have been saved by an older version of OxideSLOC. \
3458                         Re-running the analysis will create a fresh, compatible record.\n\n\
3459                         Error: {e}"
3460                    ),
3461                    &csp_nonce,
3462                );
3463            }
3464        }
3465    }
3466
3467    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3468    if let Some(entry) = reg
3469        .entries
3470        .iter_mut()
3471        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3472    {
3473        entry.html_path = Some(html_path.clone());
3474        let _ = reg.save(&state.registry_path);
3475        drop(reg);
3476        state.artifacts.lock().await.remove(&expected_run_id);
3477        return redirect_or_json_ok(want_json, &safe_redirect);
3478    }
3479
3480    drop(reg);
3481    let hint = if state.server_mode {
3482        String::new()
3483    } else {
3484        format!(
3485            "\n\nSearched folder : {}\nHTML found      : {}",
3486            scan_root.display(),
3487            html_path.display()
3488        )
3489    };
3490    locate_handler_err(
3491        want_json,
3492        format!(
3493            "Could not link this report.\n\n\
3494             No result_*.json was found in the selected folder. \
3495             Make sure you selected the top-level scan output folder \
3496             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3497        ),
3498        &csp_nonce,
3499    )
3500}
3501
3502/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3503fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3504    fs::read_dir(dir)
3505        .ok()?
3506        .flatten()
3507        .map(|e| e.path())
3508        .find(|p| {
3509            p.is_file()
3510                && p.file_stem()
3511                    .and_then(|n| n.to_str())
3512                    .is_some_and(|n| n.starts_with("result"))
3513                && p.extension()
3514                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3515        })
3516}
3517
3518#[derive(Deserialize)]
3519struct LocateReportsDirForm {
3520    folder_path: String,
3521}
3522
3523#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3524async fn locate_reports_dir_handler(
3525    State(state): State<AppState>,
3526    Form(form): Form<LocateReportsDirForm>,
3527) -> impl IntoResponse {
3528    if state.server_mode {
3529        return StatusCode::NOT_FOUND.into_response();
3530    }
3531    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3532        Ok(p) => strip_unc_prefix(p),
3533        Err(_) => {
3534            return axum::response::Redirect::to(
3535                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3536            )
3537            .into_response();
3538        }
3539    };
3540    if !folder.is_dir() {
3541        return axum::response::Redirect::to(
3542            "/view-reports?error=Selected+path+is+not+a+directory.",
3543        )
3544        .into_response();
3545    }
3546
3547    let candidates = collect_result_json_candidates(&folder);
3548
3549    if candidates.is_empty() {
3550        return axum::response::Redirect::to(
3551            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3552        )
3553        .into_response();
3554    }
3555
3556    let mut linked_count: usize = 0;
3557    let mut reg = state.registry.lock().await;
3558    for json_path in candidates {
3559        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3560            continue;
3561        };
3562        if is_dir_already_registered(&reg, &parent) {
3563            continue;
3564        }
3565        let Some(entry) = build_registry_entry_from_json(json_path) else {
3566            continue;
3567        };
3568        reg.add_entry(entry);
3569        linked_count += 1;
3570    }
3571    let _ = reg.save(&state.registry_path);
3572    drop(reg);
3573
3574    if linked_count == 0 {
3575        return axum::response::Redirect::to(
3576            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3577        )
3578        .into_response();
3579    }
3580    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3581}
3582
3583#[derive(Deserialize)]
3584struct RelocateScanForm {
3585    run_id: String,
3586    folder_path: String,
3587    redirect_url: String,
3588}
3589
3590/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3591/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3592fn relocate_folder_err(
3593    want_json: bool,
3594    status: StatusCode,
3595    msg: &str,
3596    run_id: &str,
3597    folder_hint: &str,
3598    redirect_url: &str,
3599    csp_nonce: &str,
3600) -> Response {
3601    if want_json {
3602        (
3603            status,
3604            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3605        )
3606            .into_response()
3607    } else {
3608        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3609    }
3610}
3611
3612#[allow(clippy::too_many_lines)]
3613async fn relocate_scan_handler(
3614    State(state): State<AppState>,
3615    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3616    headers: axum::http::HeaderMap,
3617    Form(form): Form<RelocateScanForm>,
3618) -> impl IntoResponse {
3619    let want_json = headers
3620        .get(axum::http::header::ACCEPT)
3621        .and_then(|v| v.to_str().ok())
3622        .is_some_and(|v| v.contains("application/json"));
3623    if state.server_mode {
3624        return StatusCode::NOT_FOUND.into_response();
3625    }
3626
3627    let run_id = form.run_id.trim().to_string();
3628    let redirect_url = form.redirect_url.trim().to_string();
3629
3630    let run_exists = {
3631        let reg = state.registry.lock().await;
3632        reg.find_by_run_id(&run_id).is_some()
3633    };
3634    if !run_exists {
3635        if want_json {
3636            return (
3637                StatusCode::NOT_FOUND,
3638                axum::Json(serde_json::json!({
3639                    "ok": false,
3640                    "message": format!("Run ID '{run_id}' not found in registry.")
3641                })),
3642            )
3643                .into_response();
3644        }
3645        let html = ErrorTemplate {
3646            message: format!("Run ID '{run_id}' not found in registry."),
3647            last_report_url: Some("/compare-scans".to_string()),
3648            last_report_label: Some("Compare Scans".to_string()),
3649            run_id: Some(run_id.clone()),
3650            error_code: Some(404),
3651            csp_nonce: csp_nonce.clone(),
3652            version: env!("CARGO_PKG_VERSION"),
3653        }
3654        .render()
3655        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3656        return Html(html).into_response();
3657    }
3658
3659    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3660        Ok(p) => strip_unc_prefix(p),
3661        Err(_) => {
3662            return relocate_folder_err(
3663                want_json,
3664                StatusCode::UNPROCESSABLE_ENTITY,
3665                "Folder not found or path is invalid.",
3666                &run_id,
3667                form.folder_path.trim(),
3668                &redirect_url,
3669                &csp_nonce,
3670            );
3671        }
3672    };
3673    if !folder.is_dir() {
3674        return relocate_folder_err(
3675            want_json,
3676            StatusCode::UNPROCESSABLE_ENTITY,
3677            "Selected path is not a directory.",
3678            &run_id,
3679            &folder.display().to_string(),
3680            &redirect_url,
3681            &csp_nonce,
3682        );
3683    }
3684
3685    let json_candidates = find_result_files_by_ext(&folder, "json");
3686    if json_candidates.is_empty() {
3687        let msg = format!(
3688            "No result JSON files found in the selected folder.\nSearched: {}",
3689            folder.display()
3690        );
3691        return relocate_folder_err(
3692            want_json,
3693            StatusCode::UNPROCESSABLE_ENTITY,
3694            &msg,
3695            &run_id,
3696            &folder.display().to_string(),
3697            &redirect_url,
3698            &csp_nonce,
3699        );
3700    }
3701
3702    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3703        let msg = format!(
3704            "No matching scan found in the selected folder.\n\
3705             The JSON files present do not contain run ID: {run_id}\n\
3706             Searched: {}",
3707            folder.display()
3708        );
3709        return relocate_folder_err(
3710            want_json,
3711            StatusCode::UNPROCESSABLE_ENTITY,
3712            &msg,
3713            &run_id,
3714            &folder.display().to_string(),
3715            &redirect_url,
3716            &csp_nonce,
3717        );
3718    };
3719
3720    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3721    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3722    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3723
3724    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3725        redirect_url
3726    } else {
3727        "/compare-scans".to_string()
3728    };
3729    redirect_or_json_ok(want_json, &safe_redirect)
3730}
3731
3732fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3733    let mut out = Vec::new();
3734    collect_scan_files_by_ext(folder, ext, &mut out);
3735    if let Ok(rd) = fs::read_dir(folder) {
3736        for entry in rd.flatten() {
3737            let sub = entry.path();
3738            if sub.is_dir() {
3739                collect_scan_files_by_ext(&sub, ext, &mut out);
3740            }
3741        }
3742    }
3743    out
3744}
3745
3746fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
3747    let Ok(rd) = fs::read_dir(dir) else { return };
3748    for entry in rd.flatten() {
3749        let p = entry.path();
3750        if p.is_file()
3751            && p.file_stem()
3752                .and_then(|n| n.to_str())
3753                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3754            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
3755        {
3756            out.push(p);
3757        }
3758    }
3759}
3760
3761fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
3762    candidates
3763        .iter()
3764        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
3765        .cloned()
3766}
3767
3768/// Return the best folder hint for the relocate page.
3769/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
3770/// point at the parent — the actual top-level output directory — so the user
3771/// selects the root folder rather than the subfolder.
3772fn output_folder_hint(json_path: &std::path::Path) -> String {
3773    let Some(direct_parent) = json_path.parent() else {
3774        return String::new();
3775    };
3776    let parent_name = direct_parent
3777        .file_name()
3778        .and_then(|n| n.to_str())
3779        .unwrap_or("");
3780    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
3781        direct_parent.parent().map_or_else(
3782            || direct_parent.display().to_string(),
3783            |p| p.display().to_string(),
3784        )
3785    } else {
3786        direct_parent.display().to_string()
3787    }
3788}
3789
3790async fn update_run_file_paths(
3791    state: &AppState,
3792    run_id: &str,
3793    json_path: PathBuf,
3794    html_path: Option<PathBuf>,
3795    pdf_path: Option<PathBuf>,
3796) {
3797    {
3798        let mut reg = state.registry.lock().await;
3799        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3800            entry.json_path = Some(json_path.clone());
3801            if let Some(ref hp) = html_path {
3802                entry.html_path = Some(hp.clone());
3803            }
3804            if let Some(ref pp) = pdf_path {
3805                entry.pdf_path = Some(pp.clone());
3806            }
3807        }
3808        let _ = reg.save(&state.registry_path);
3809    }
3810    // Also patch the in-memory artifacts map so the result page picks up the
3811    // new paths without requiring a server restart.
3812    {
3813        let mut map = state.artifacts.lock().await;
3814        if let Some(arts) = map.get_mut(run_id) {
3815            arts.json_path = Some(json_path);
3816            if let Some(hp) = html_path {
3817                arts.html_path = Some(hp);
3818            }
3819            if let Some(pp) = pdf_path {
3820                arts.pdf_path = Some(pp);
3821            }
3822        }
3823    }
3824}
3825
3826fn missing_scan_relocate_response(
3827    message: &str,
3828    run_id: &str,
3829    folder_hint: &str,
3830    redirect_url: &str,
3831    server_mode: bool,
3832    csp_nonce: &str,
3833) -> axum::response::Response {
3834    let html = RelocateScanTemplate {
3835        message: message.to_string(),
3836        run_id: run_id.to_string(),
3837        folder_hint: folder_hint.to_string(),
3838        redirect_url: redirect_url.to_string(),
3839        server_mode,
3840        csp_nonce: csp_nonce.to_owned(),
3841        version: env!("CARGO_PKG_VERSION"),
3842    }
3843    .render()
3844    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3845    (StatusCode::NOT_FOUND, Html(html)).into_response()
3846}
3847
3848// ── Watched-directory helpers ─────────────────────────────────────────────────
3849
3850/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
3851fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
3852    fs::read_dir(dir)
3853        .ok()?
3854        .flatten()
3855        .map(|e| e.path())
3856        .find(|p| {
3857            p.is_file()
3858                && p.extension()
3859                    .and_then(|e| e.to_str())
3860                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
3861        })
3862}
3863
3864/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
3865/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
3866/// (`<scan_dir>/json/result*.json`).
3867fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
3868    let mut out = Vec::new();
3869    if let Some(j) = find_result_json_in_dir(sub) {
3870        out.push(j);
3871    }
3872    let json_sub = sub.join("json");
3873    if json_sub.is_dir() {
3874        if let Some(j) = find_result_json_in_dir(&json_sub) {
3875            out.push(j);
3876        }
3877    }
3878    out
3879}
3880
3881fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
3882    let mut candidates = Vec::new();
3883    if let Some(j) = find_result_json_in_dir(folder) {
3884        candidates.push(j);
3885    }
3886    let Ok(dir_entries) = fs::read_dir(folder) else {
3887        return candidates;
3888    };
3889    for entry in dir_entries.flatten() {
3890        let sub = entry.path();
3891        if sub.is_dir() {
3892            candidates.extend(subdir_result_json_candidates(&sub));
3893        }
3894    }
3895    candidates
3896}
3897
3898fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
3899    reg.entries.iter().any(|e| {
3900        let dir_match = e
3901            .json_path
3902            .as_ref()
3903            .and_then(|p| p.parent())
3904            .is_some_and(|p| p == parent)
3905            || e.html_path
3906                .as_ref()
3907                .and_then(|p| p.parent())
3908                .is_some_and(|p| p == parent);
3909        dir_match
3910            && (e.json_path.as_ref().is_some_and(|p| p.exists())
3911                || e.html_path.as_ref().is_some_and(|p| p.exists()))
3912    })
3913}
3914
3915fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
3916    let json_dir = json_path.parent()?.to_path_buf();
3917    // If the JSON lives inside a directory named "json", the scan root is its parent
3918    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
3919    let (html_path, pdf_path, csv_path, xlsx_path) =
3920        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
3921            let scan_root = json_dir.parent()?;
3922            let html = find_html_report_in_dir(&scan_root.join("html"))
3923                .or_else(|| find_html_report_in_dir(scan_root));
3924            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
3925            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
3926            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
3927            (html, pdf, csv, xlsx)
3928        } else {
3929            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
3930                rd.flatten()
3931                    .map(|e| e.path())
3932                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
3933            });
3934            (html, None, None, None)
3935        };
3936    let run = read_json(&json_path).ok()?;
3937    let project_label = run.input_roots.first().map_or_else(
3938        || "Unknown Project".to_string(),
3939        |r| sanitize_project_label(r),
3940    );
3941    Some(RegistryEntry {
3942        run_id: run.tool.run_id.clone(),
3943        timestamp_utc: run.tool.timestamp_utc,
3944        project_label,
3945        input_roots: run.input_roots.clone(),
3946        json_path: Some(json_path),
3947        html_path,
3948        pdf_path,
3949        csv_path,
3950        xlsx_path,
3951        summary: ScanSummarySnapshot::from(&run.summary_totals),
3952        git_branch: run.git_branch.clone(),
3953        git_commit: run.git_commit_short.clone(),
3954        git_commit_long: run.git_commit_long.clone(),
3955        git_author: run.git_commit_author.clone(),
3956        git_tags: run.git_tags.clone(),
3957        git_nearest_tag: run.git_nearest_tag.clone(),
3958        git_commit_date: run.git_commit_date,
3959    })
3960}
3961
3962/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
3963/// Returns the number of newly linked entries.
3964fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
3965    let mut linked = 0usize;
3966    for json_path in collect_result_json_candidates(folder) {
3967        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3968            continue;
3969        };
3970        if is_dir_already_registered(reg, &parent) {
3971            continue;
3972        }
3973        let Some(entry) = build_registry_entry_from_json(json_path) else {
3974            continue;
3975        };
3976        reg.add_entry(entry);
3977        linked += 1;
3978    }
3979    linked
3980}
3981
3982/// Scan all watched directories (plus the default output root) into `reg`.
3983async fn auto_scan_watched_dirs(state: &AppState) {
3984    let dirs: Vec<PathBuf> = {
3985        let wd = state.watched_dirs.lock().await;
3986        wd.dirs.clone()
3987    };
3988    // Reconcile the registry to the watched-folder model: keep only entries under a
3989    // currently-watched folder or the app's own output directory. This drops leftovers from
3990    // folders that have since been un-watched (which would otherwise linger in the list).
3991    {
3992        let output_root = resolve_output_root(None);
3993        let mut roots: Vec<PathBuf> = dirs.clone();
3994        if let Ok(canon) = fs::canonicalize(&output_root) {
3995            roots.push(strip_unc_prefix(canon));
3996        }
3997        roots.push(output_root);
3998        let mut reg = state.registry.lock().await;
3999        if reg.retain_under_roots(&roots) > 0 {
4000            let _ = reg.save(&state.registry_path);
4001        }
4002    }
4003    if dirs.is_empty() {
4004        return;
4005    }
4006    let mut reg = state.registry.lock().await;
4007    let mut total = 0usize;
4008    for dir in &dirs {
4009        if dir.is_dir() {
4010            total += scan_folder_into_registry(dir, &mut reg);
4011        }
4012    }
4013    if total > 0 {
4014        let _ = reg.save(&state.registry_path);
4015    }
4016}
4017
4018// ── Watched-dir route forms ───────────────────────────────────────────────────
4019
4020#[derive(Deserialize)]
4021struct WatchedDirForm {
4022    folder_path: String,
4023    #[serde(default = "default_redirect")]
4024    redirect_to: String,
4025}
4026
4027fn default_redirect() -> String {
4028    "/view-reports".to_string()
4029}
4030
4031#[derive(Deserialize)]
4032struct WatchedDirRefreshForm {
4033    #[serde(default = "default_redirect")]
4034    redirect_to: String,
4035}
4036
4037// ── Watched-dir helpers ───────────────────────────────────────────────────────
4038
4039/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
4040fn safe_redirect(dest: &str) -> &str {
4041    if dest.starts_with('/') {
4042        dest
4043    } else {
4044        "/"
4045    }
4046}
4047
4048// ── Watched-dir handlers ──────────────────────────────────────────────────────
4049
4050async fn add_watched_dir_handler(
4051    State(state): State<AppState>,
4052    Form(form): Form<WatchedDirForm>,
4053) -> impl IntoResponse {
4054    if state.server_mode {
4055        return StatusCode::NOT_FOUND.into_response();
4056    }
4057    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4058        strip_unc_prefix(p)
4059    } else {
4060        let dest = format!(
4061            "{}?error=Folder+not+found+or+path+is+invalid.",
4062            safe_redirect(&form.redirect_to)
4063        );
4064        return axum::response::Redirect::to(&dest).into_response();
4065    };
4066    if !folder.is_dir() {
4067        let dest = format!(
4068            "{}?error=Selected+path+is+not+a+directory.",
4069            safe_redirect(&form.redirect_to)
4070        );
4071        return axum::response::Redirect::to(&dest).into_response();
4072    }
4073
4074    // Persist the watched directory.
4075    {
4076        let mut wd = state.watched_dirs.lock().await;
4077        wd.add(folder.clone());
4078        let _ = wd.save(&state.watched_dirs_path);
4079    }
4080
4081    // Immediately scan the folder and add any new reports.
4082    let linked = {
4083        let mut reg = state.registry.lock().await;
4084        let n = scan_folder_into_registry(&folder, &mut reg);
4085        if n > 0 {
4086            let _ = reg.save(&state.registry_path);
4087        }
4088        n
4089    };
4090
4091    let dest = if linked > 0 {
4092        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4093    } else {
4094        format!(
4095            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4096            safe_redirect(&form.redirect_to)
4097        )
4098    };
4099    axum::response::Redirect::to(&dest).into_response()
4100}
4101
4102async fn remove_watched_dir_handler(
4103    State(state): State<AppState>,
4104    Form(form): Form<WatchedDirForm>,
4105) -> impl IntoResponse {
4106    if state.server_mode {
4107        return StatusCode::NOT_FOUND.into_response();
4108    }
4109    let folder = PathBuf::from(&form.folder_path);
4110    {
4111        let mut wd = state.watched_dirs.lock().await;
4112        wd.remove(&folder);
4113        let _ = wd.save(&state.watched_dirs_path);
4114    }
4115    // Drop any reports that were linked in from this folder so the list reflects the removal.
4116    {
4117        let mut reg = state.registry.lock().await;
4118        if reg.remove_entries_under(&folder) > 0 {
4119            let _ = reg.save(&state.registry_path);
4120        }
4121    }
4122    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4123}
4124
4125async fn refresh_watched_dirs_handler(
4126    State(state): State<AppState>,
4127    Form(form): Form<WatchedDirRefreshForm>,
4128) -> impl IntoResponse {
4129    if state.server_mode {
4130        return StatusCode::NOT_FOUND.into_response();
4131    }
4132    let dirs: Vec<PathBuf> = {
4133        let wd = state.watched_dirs.lock().await;
4134        wd.dirs.clone()
4135    };
4136    let mut total = 0usize;
4137    {
4138        let mut reg = state.registry.lock().await;
4139        reg.prune_stale();
4140        for dir in &dirs {
4141            if dir.is_dir() {
4142                total += scan_folder_into_registry(dir, &mut reg);
4143            }
4144        }
4145        let _ = reg.save(&state.registry_path);
4146    }
4147    let dest = if total > 0 {
4148        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4149    } else {
4150        safe_redirect(&form.redirect_to).to_owned()
4151    };
4152    axum::response::Redirect::to(&dest).into_response()
4153}
4154
4155#[derive(Debug, Deserialize)]
4156struct OpenPathQuery {
4157    path: Option<String>,
4158}
4159
4160fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4161    let mut ancestor = std::path::Path::new(raw);
4162    loop {
4163        match ancestor.parent() {
4164            Some(p) => {
4165                ancestor = p;
4166                if ancestor.is_dir() {
4167                    break;
4168                }
4169            }
4170            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4171        }
4172    }
4173    Ok(ancestor.to_path_buf())
4174}
4175
4176async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4177    match tokio::fs::canonicalize(raw).await {
4178        Ok(canonical) if canonical.is_file() => canonical
4179            .parent()
4180            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4181                Ok(p.to_path_buf())
4182            }),
4183        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4184        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4185        Err(_) => find_existing_ancestor(raw),
4186    }
4187}
4188
4189async fn open_path_handler(
4190    State(state): State<AppState>,
4191    Query(query): Query<OpenPathQuery>,
4192) -> impl IntoResponse {
4193    if state.server_mode {
4194        return Json(serde_json::json!({
4195            "server_mode_disabled": true,
4196            "message": "Opening a path in the file manager is only available in local desktop mode."
4197        }))
4198        .into_response();
4199    }
4200    // Skip the OS file-manager call in headless / CI environments.
4201    if std::env::var("SLOC_HEADLESS").is_ok() {
4202        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4203    }
4204    let raw = match query.path.as_deref() {
4205        Some(p) if !p.is_empty() => p,
4206        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4207    };
4208
4209    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4210    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4211    // so the file explorer still opens somewhere useful.
4212    let target = match resolve_open_target(raw).await {
4213        Ok(p) => p,
4214        Err((code, msg)) => return (code, msg).into_response(),
4215    };
4216
4217    #[cfg(target_os = "windows")]
4218    win_dialog_focus::open_folder_foreground(target);
4219    #[cfg(target_os = "macos")]
4220    let _ = std::process::Command::new("open")
4221        .arg(&target)
4222        .stdout(Stdio::null())
4223        .stderr(Stdio::null())
4224        .spawn();
4225    #[cfg(target_os = "linux")]
4226    {
4227        let folder_name = target
4228            .file_name()
4229            .and_then(|n| n.to_str())
4230            .map(str::to_owned);
4231        let _ = std::process::Command::new("xdg-open")
4232            .arg(&target)
4233            .stdout(Stdio::null())
4234            .stderr(Stdio::null())
4235            .spawn();
4236        // Best-effort: raise the file manager window once it appears.
4237        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4238        // installed; failures are silently discarded.
4239        if let Some(name) = folder_name {
4240            std::thread::spawn(move || {
4241                std::thread::sleep(std::time::Duration::from_millis(800));
4242                let _ = std::process::Command::new("wmctrl")
4243                    .args(["-a", &name])
4244                    .stdout(Stdio::null())
4245                    .stderr(Stdio::null())
4246                    .spawn();
4247            });
4248        }
4249    }
4250
4251    Json(serde_json::json!({"ok": true})).into_response()
4252}
4253
4254async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4255    let (content_type, bytes): (&'static str, &'static [u8]) =
4256        match (folder.as_str(), file.as_str()) {
4257            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4258            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4259            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4260            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4261            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4262            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4263            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4264            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4265            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4266            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4267            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4268            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4269            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4270            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4271            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4272            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4273            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4274            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4275            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4276            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4277            _ => return StatusCode::NOT_FOUND.into_response(),
4278        };
4279    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4280}
4281
4282/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4283/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4284/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4285/// complexity low; the fail-closed semantics are unchanged.
4286fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4287    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4288    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4289    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4290    // sample/upload locations are permitted; everything else is rejected.
4291    let Ok(canonical) = fs::canonicalize(resolved) else {
4292        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4293            return Err(Html(
4294                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4295            ));
4296        }
4297        return Ok(());
4298    };
4299    // Upload temp dirs and built-in sample/fixture paths are always safe.
4300    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4301        return Ok(());
4302    }
4303    let config = &state.base_config;
4304    if config.discovery.allowed_scan_roots.is_empty() {
4305        return Err(Html(
4306            r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4307        ));
4308    }
4309    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4310        fs::canonicalize(root)
4311            .ok()
4312            .is_some_and(|r| canonical.starts_with(&r))
4313    });
4314    if !allowed {
4315        return Err(Html(
4316            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4317        ));
4318    }
4319    Ok(())
4320}
4321
4322async fn preview_handler(
4323    State(state): State<AppState>,
4324    Query(query): Query<PreviewQuery>,
4325) -> impl IntoResponse {
4326    let raw_path = query
4327        .path
4328        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4329    let resolved = resolve_input_path(&raw_path);
4330
4331    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4332    // binary whose working directory is not the project root), return a clear message
4333    // instead of an opaque OS error from build_preview_html.
4334    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4335        return Html(
4336            r#"<div class="preview-error">Sample directory not available on this server.
4337            Enter a path to a project directory or upload files using Browse.</div>"#
4338                .to_string(),
4339        );
4340    }
4341
4342    if state.server_mode {
4343        if let Err(resp) = authorize_preview_path(&state, &resolved) {
4344            return resp;
4345        }
4346    }
4347
4348    let include_patterns = split_patterns(query.include_globs.as_deref());
4349    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4350
4351    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4352        Ok(html) => Html(html),
4353        Err(err) => Html(format!(
4354            r#"<div class="preview-error">Preview failed: {}</div>"#,
4355            escape_html(&err.to_string())
4356        )),
4357    }
4358}
4359
4360#[derive(Debug, Deserialize, Default)]
4361struct SuggestCoverageQuery {
4362    path: Option<String>,
4363}
4364
4365#[derive(Serialize)]
4366struct SuggestCoverageResponse {
4367    found: Option<String>,
4368    tool: Option<&'static str>,
4369    hint: Option<&'static str>,
4370}
4371
4372async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4373    const CANDIDATES: &[&str] = &[
4374        // LCOV — cargo-llvm-cov, gcov, lcov
4375        "coverage/lcov.info",
4376        "lcov.info",
4377        "target/llvm-cov/lcov.info",
4378        "target/coverage/lcov.info",
4379        "target/debug/coverage/lcov.info",
4380        "coverage/coverage.lcov",
4381        "build/coverage/lcov.info",
4382        "reports/lcov.info",
4383        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4384        "coverage.xml",
4385        "coverage/coverage.xml",
4386        "target/site/cobertura/coverage.xml",
4387        "build/reports/coverage/coverage.xml",
4388        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4389        "target/site/jacoco/jacoco.xml",
4390        "build/reports/jacoco/test/jacocoTestReport.xml",
4391        "build/reports/jacoco/jacocoTestReport.xml",
4392        "build/jacoco/jacoco.xml",
4393        // coverage.py native JSON — `coverage json`
4394        "coverage.json",
4395        "coverage/coverage.json",
4396    ];
4397    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4398    let found = CANDIDATES
4399        .iter()
4400        .map(|rel| root.join(rel))
4401        .find(|p| p.is_file())
4402        .map(|p| display_path(&p));
4403
4404    let (tool, hint) = detect_coverage_tool(&root);
4405    Json(SuggestCoverageResponse { found, tool, hint })
4406}
4407
4408/// Inspect the project root for known build/package files and return the most likely coverage
4409/// tool name and the shell command needed to generate a coverage file.
4410fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4411    if root.join("Cargo.toml").is_file() {
4412        return (
4413            Some("cargo-llvm-cov"),
4414            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4415        );
4416    }
4417    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4418        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4419    }
4420    if root.join("pom.xml").is_file() {
4421        return (Some("jacoco"), Some("mvn test jacoco:report"));
4422    }
4423    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4424        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4425    }
4426    (None, None)
4427}
4428
4429/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4430#[allow(clippy::result_large_err)]
4431fn validate_server_scan_path(
4432    config: &sloc_config::AppConfig,
4433    resolved_path: &Path,
4434    csp_nonce: &str,
4435) -> Result<(), Response> {
4436    if config.discovery.allowed_scan_roots.is_empty() {
4437        let template = ErrorTemplate {
4438            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4439                      Set allowed_scan_roots in the server config to permit scanning."
4440                .to_string(),
4441            last_report_url: None,
4442            last_report_label: None,
4443            run_id: None,
4444            error_code: Some(403),
4445            csp_nonce: csp_nonce.to_owned(),
4446            version: env!("CARGO_PKG_VERSION"),
4447        };
4448        return Err((
4449            StatusCode::FORBIDDEN,
4450            Html(
4451                template
4452                    .render()
4453                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4454            ),
4455        )
4456            .into_response());
4457    }
4458    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4459    // location) we must NOT fall back to the raw, un-normalised path — a textual
4460    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4461    // allowlist. A non-resolvable scan target is rejected outright.
4462    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4463        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4464            "Scan path does not resolve to a real location");
4465        let template = ErrorTemplate {
4466            message: "The requested path could not be resolved to a real directory.".to_string(),
4467            last_report_url: None,
4468            last_report_label: None,
4469            run_id: None,
4470            error_code: Some(403),
4471            csp_nonce: csp_nonce.to_owned(),
4472            version: env!("CARGO_PKG_VERSION"),
4473        };
4474        return Err((
4475            StatusCode::FORBIDDEN,
4476            Html(
4477                template
4478                    .render()
4479                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4480            ),
4481        )
4482            .into_response());
4483    };
4484    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4485        fs::canonicalize(root)
4486            .ok()
4487            .is_some_and(|r| canonical.starts_with(&r))
4488    });
4489    if !allowed {
4490        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4491            "Scan path not in allowed_scan_roots");
4492        let template = ErrorTemplate {
4493            message: "The requested path is not within an allowed scan directory.".to_string(),
4494            last_report_url: None,
4495            last_report_label: None,
4496            run_id: None,
4497            error_code: Some(403),
4498            csp_nonce: csp_nonce.to_owned(),
4499            version: env!("CARGO_PKG_VERSION"),
4500        };
4501        return Err((
4502            StatusCode::FORBIDDEN,
4503            Html(
4504                template
4505                    .render()
4506                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4507            ),
4508        )
4509            .into_response());
4510    }
4511    Ok(())
4512}
4513
4514/// Exclude the output directory from scanning so artifacts don't pollute counts.
4515fn apply_output_dir_exclusions(
4516    config: &mut sloc_config::AppConfig,
4517    project_path: &str,
4518    raw_output_dir: &str,
4519) {
4520    let project_root = resolve_input_path(project_path);
4521    let raw_out = raw_output_dir.trim();
4522    let resolved_out = if raw_out.is_empty() {
4523        project_root.join("sloc")
4524    } else if Path::new(raw_out).is_absolute() {
4525        PathBuf::from(raw_out)
4526    } else {
4527        workspace_root().join(raw_out)
4528    };
4529    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4530        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4531            let dir = first.to_string();
4532            if !config.discovery.excluded_directories.contains(&dir) {
4533                config.discovery.excluded_directories.push(dir);
4534            }
4535        }
4536    }
4537    if !config
4538        .discovery
4539        .excluded_directories
4540        .iter()
4541        .any(|d| d == "sloc")
4542    {
4543        config
4544            .discovery
4545            .excluded_directories
4546            .push("sloc".to_string());
4547    }
4548}
4549
4550/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4551const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4552    ScanSummarySnapshot {
4553        files_analyzed: run.summary_totals.files_analyzed,
4554        files_skipped: run.summary_totals.files_skipped,
4555        total_physical_lines: run.summary_totals.total_physical_lines,
4556        code_lines: run.summary_totals.code_lines,
4557        comment_lines: run.summary_totals.comment_lines,
4558        blank_lines: run.summary_totals.blank_lines,
4559        functions: run.summary_totals.functions,
4560        classes: run.summary_totals.classes,
4561        variables: run.summary_totals.variables,
4562        imports: run.summary_totals.imports,
4563        test_count: run.summary_totals.test_count,
4564        coverage_lines_found: run.summary_totals.coverage_lines_found,
4565        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4566        coverage_functions_found: run.summary_totals.coverage_functions_found,
4567        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4568        coverage_branches_found: run.summary_totals.coverage_branches_found,
4569        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4570    }
4571}
4572
4573/// Build the `RegistryEntry` for the just-completed scan run.
4574pub(crate) fn build_run_registry_entry(
4575    run: &AnalysisRun,
4576    run_id: &str,
4577    project_label: &str,
4578    artifacts: &RunArtifacts,
4579) -> RegistryEntry {
4580    RegistryEntry {
4581        run_id: run_id.to_owned(),
4582        timestamp_utc: run.tool.timestamp_utc,
4583        project_label: project_label.to_owned(),
4584        input_roots: run.input_roots.clone(),
4585        json_path: artifacts.json_path.clone(),
4586        html_path: artifacts.html_path.clone(),
4587        pdf_path: artifacts.pdf_path.clone(),
4588        csv_path: artifacts.csv_path.clone(),
4589        xlsx_path: artifacts.xlsx_path.clone(),
4590        summary: summary_snapshot_from_run(run),
4591        git_branch: run.git_branch.clone(),
4592        git_commit: run.git_commit_short.clone(),
4593        git_commit_long: run.git_commit_long.clone(),
4594        git_author: run.git_commit_author.clone(),
4595        git_tags: run.git_tags.clone(),
4596        git_nearest_tag: run.git_nearest_tag.clone(),
4597        git_commit_date: run.git_commit_date.clone(),
4598    }
4599}
4600
4601/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4602fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4603    if let Some(policy) = form.mixed_line_policy {
4604        config.analysis.mixed_line_policy = policy;
4605    }
4606    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4607    config.analysis.generated_file_detection =
4608        form.generated_file_detection.as_deref() != Some("disabled");
4609    config.analysis.minified_file_detection =
4610        form.minified_file_detection.as_deref() != Some("disabled");
4611    config.analysis.vendor_directory_detection =
4612        form.vendor_directory_detection.as_deref() != Some("disabled");
4613    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4614    if let Some(binary_behavior) = form.binary_file_behavior {
4615        config.analysis.binary_file_behavior = binary_behavior;
4616    }
4617    apply_report_opts(config, form);
4618    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4619    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4620    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4621    if let Some(policy) = form.continuation_line_policy {
4622        config.analysis.continuation_line_policy = policy;
4623    }
4624    if let Some(policy) = form.blank_in_block_comment_policy {
4625        config.analysis.blank_in_block_comment_policy = policy;
4626    }
4627    config.analysis.count_compiler_directives =
4628        form.count_compiler_directives.as_deref() != Some("disabled");
4629    apply_style_threshold(config, form);
4630    apply_coverage_path(config, form);
4631}
4632
4633fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4634    if let Some(report_title) = form.report_title.as_deref() {
4635        let trimmed = report_title.trim();
4636        if !trimmed.is_empty() {
4637            config.reporting.report_title = trimmed.to_string();
4638        }
4639    }
4640    if let Some(hf) = form.report_header_footer.as_deref() {
4641        let trimmed = hf.trim();
4642        config.reporting.report_header_footer = if trimmed.is_empty() {
4643            None
4644        } else {
4645            Some(trimmed.to_string())
4646        };
4647    }
4648}
4649
4650fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4651    apply_style_col_threshold(config, form);
4652    apply_style_analysis_enabled(config, form);
4653    apply_style_score_threshold(config, form);
4654    apply_style_lang_scope(config, form);
4655    apply_activity_window(config, form);
4656}
4657
4658fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4659    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4660        if let Ok(t) = threshold_str.parse::<u16>() {
4661            if t == 80 || t == 100 || t == 120 {
4662                config.analysis.style_col_threshold = t;
4663            }
4664        }
4665    }
4666}
4667
4668fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4669    if let Some(v) = form.style_analysis_enabled.as_deref() {
4670        config.analysis.style_analysis_enabled = v != "disabled";
4671    }
4672}
4673
4674fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4675    if let Some(v) = form.style_score_threshold.as_deref() {
4676        if let Ok(t) = v.parse::<u8>() {
4677            config.analysis.style_score_threshold = t.min(100);
4678        }
4679    }
4680}
4681
4682fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4683    if let Some(v) = form.style_lang_scope.as_deref() {
4684        let scope = v.trim();
4685        if scope == "c_family" || scope == "all" {
4686            config.analysis.style_lang_scope = scope.to_string();
4687        }
4688    }
4689}
4690
4691fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4692    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
4693    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
4694    if let Some(w) = form.activity_window.as_deref() {
4695        let w = w.trim();
4696        if !w.is_empty() {
4697            if let Ok(days) = w.parse::<u32>() {
4698                config.analysis.activity_window_days = Some(days);
4699            }
4700        }
4701    }
4702}
4703
4704fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4705    if let Some(cov) = &form.coverage_file {
4706        let trimmed = cov.trim();
4707        if !trimmed.is_empty() {
4708            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4709        }
4710    }
4711}
4712
4713/// Fire-and-forget: generate the PDF in a background task if one is pending.
4714/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4715/// an error instead of spinning indefinitely.
4716fn spawn_pdf_background(
4717    pending_pdf: PendingPdf,
4718    run_id: String,
4719    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4720) {
4721    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4722        tokio::spawn(async move {
4723            let result = tokio::task::spawn_blocking(move || {
4724                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4725                if cleanup_src {
4726                    let _ = fs::remove_file(&pdf_src);
4727                }
4728                r
4729            })
4730            .await;
4731            let failed = match result {
4732                Ok(Ok(())) => false,
4733                Ok(Err(err)) => {
4734                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4735                    true
4736                }
4737                Err(err) => {
4738                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
4739                    true
4740                }
4741            };
4742            if failed {
4743                let mut map = artifacts.lock().await;
4744                if let Some(entry) = map.get_mut(&run_id) {
4745                    entry.pdf_path = None;
4746                }
4747            }
4748        });
4749    }
4750}
4751
4752/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
4753/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
4754/// result page can show an error on the next visit instead of spinning indefinitely.
4755fn spawn_native_pdf_background(
4756    json_path: PathBuf,
4757    pdf_dest: PathBuf,
4758    run_id: String,
4759    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4760) {
4761    tokio::spawn(async move {
4762        let result = tokio::task::spawn_blocking(move || {
4763            let run = sloc_core::read_json(&json_path)?;
4764            write_pdf_from_run(&run, &pdf_dest)
4765        })
4766        .await;
4767        let failed = match result {
4768            Ok(Ok(())) => false,
4769            Ok(Err(err)) => {
4770                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
4771                true
4772            }
4773            Err(err) => {
4774                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
4775                true
4776            }
4777        };
4778        if failed {
4779            let mut map = artifacts.lock().await;
4780            if let Some(entry) = map.get_mut(&run_id) {
4781                entry.pdf_path = None;
4782            }
4783        }
4784    });
4785}
4786
4787/// Sum the code lines added in this comparison (new + grown files).
4788fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4789    cmp.file_deltas
4790        .iter()
4791        .map(|f| match f.status {
4792            FileChangeStatus::Added => f.current_code,
4793            FileChangeStatus::Modified => f.code_delta.max(0),
4794            _ => 0,
4795        })
4796        .sum()
4797}
4798
4799/// Sum the code lines removed in this comparison (deleted + shrunk files).
4800fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4801    cmp.file_deltas
4802        .iter()
4803        .map(|f| match f.status {
4804            FileChangeStatus::Removed => f.baseline_code,
4805            FileChangeStatus::Modified => (-f.code_delta).max(0),
4806            _ => 0,
4807        })
4808        .sum()
4809}
4810
4811/// Sum the code lines present in both scans without any change (Unchanged files).
4812fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4813    cmp.file_deltas
4814        .iter()
4815        .filter(|f| f.status == FileChangeStatus::Unchanged)
4816        .map(|f| f.current_code)
4817        .sum()
4818}
4819
4820/// Sum the code lines residing in files that were modified between the two scans.
4821fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
4822    cmp.file_deltas
4823        .iter()
4824        .filter(|f| f.status == FileChangeStatus::Modified)
4825        .map(|f| f.current_code)
4826        .sum()
4827}
4828
4829/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
4830fn build_submodule_row(
4831    s: &sloc_core::SubmoduleSummary,
4832    run: &AnalysisRun,
4833    run_id: &str,
4834    run_dir: &Path,
4835) -> SubmoduleRow {
4836    let safe = sanitize_project_label(&s.name);
4837    let artifact_key = format!("sub_{safe}");
4838    let pdf_artifact_key = format!("sub_{safe}_pdf");
4839    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
4840        let parent_path = run
4841            .input_roots
4842            .first()
4843            .map_or("", std::string::String::as_str);
4844        let sub_run = build_sub_run(run, s, parent_path);
4845        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
4846        render_sub_report_html(&sub_run, Some(&pdf_server_url))
4847            .ok()
4848            .and_then(|sub_html| {
4849                let sub_dir = run_dir.join("submodules");
4850                let _ = fs::create_dir_all(&sub_dir);
4851                let html_path = sub_dir.join(format!("{artifact_key}.html"));
4852                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
4853                    // Pre-generate the sub-report PDF using the programmatic renderer
4854                    // so "View PDF" never needs to spawn Chrome for submodules.
4855                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
4856                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
4857                    Some(format!("/runs/{artifact_key}/{run_id}"))
4858                } else {
4859                    None
4860                }
4861            })
4862    } else {
4863        None
4864    };
4865    SubmoduleRow {
4866        name: s.name.clone(),
4867        relative_path: s.relative_path.clone(),
4868        files_analyzed: s.files_analyzed,
4869        code_lines: s.code_lines,
4870        comment_lines: s.comment_lines,
4871        blank_lines: s.blank_lines,
4872        total_physical_lines: s.total_physical_lines,
4873        html_url,
4874    }
4875}
4876
4877// Immediately returns a wait page and runs the analysis in a background tokio task.
4878// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
4879#[allow(clippy::similar_names)]
4880#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
4881#[allow(clippy::too_many_lines)]
4882async fn analyze_handler(
4883    State(state): State<AppState>,
4884    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4885    Form(form): Form<AnalyzeForm>,
4886) -> impl IntoResponse {
4887    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
4888        let template = ErrorTemplate {
4889            message: format!(
4890                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
4891             Please wait a moment and try again."
4892            ),
4893            last_report_url: None,
4894            last_report_label: None,
4895            run_id: None,
4896            error_code: Some(503),
4897            csp_nonce: csp_nonce.clone(),
4898            version: env!("CARGO_PKG_VERSION"),
4899        };
4900        return (
4901            StatusCode::SERVICE_UNAVAILABLE,
4902            Html(
4903                template
4904                    .render()
4905                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
4906            ),
4907        )
4908            .into_response();
4909    };
4910
4911    let mut config = state.base_config.clone();
4912
4913    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
4914    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
4915    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
4916
4917    if !is_git_mode {
4918        let resolved_path = resolve_input_path(&form.path);
4919        if state.server_mode
4920            && !is_upload_tmp_path(&resolved_path)
4921            && !is_sample_path(&resolved_path)
4922        {
4923            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
4924                return resp;
4925            }
4926        }
4927        config.discovery.root_paths = vec![resolved_path];
4928    }
4929
4930    apply_form_to_config(&mut config, &form);
4931    apply_output_dir_exclusions(
4932        &mut config,
4933        &form.path,
4934        form.output_dir.as_deref().unwrap_or(""),
4935    );
4936
4937    // Generate a wait_id now (before spawning) so the client can poll for status.
4938    let wait_id = uuid::Uuid::new_v4().to_string();
4939    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
4940
4941    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
4942    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
4943    let task_cancel = Arc::clone(&cancel_token);
4944
4945    // Phase tracker: updated by run_analysis_task at key checkpoints.
4946    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
4947    let task_phase = Arc::clone(&phase);
4948
4949    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4950    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4951    let task_files_done = Arc::clone(&files_done);
4952    let task_files_total = Arc::clone(&files_total);
4953
4954    // Register Running state before building the task struct so the semaphore permit
4955    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
4956    {
4957        let mut runs = state.async_runs.lock().await;
4958        runs.insert(
4959            wait_id.clone(),
4960            AsyncRunState::Running {
4961                started_at: std::time::Instant::now(),
4962                cancel_token,
4963                phase,
4964                files_done,
4965                files_total,
4966            },
4967        );
4968    }
4969
4970    let task = AnalysisTask {
4971        sem_permit,
4972        state: state.clone(),
4973        wait_id: wait_id.clone(),
4974        config,
4975        cancel: task_cancel,
4976        phase: task_phase,
4977        files_done: task_files_done,
4978        files_total: task_files_total,
4979        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
4980        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
4981        project_path: form.path.clone(),
4982        // In server mode the client-supplied output_dir is ignored — artifacts are
4983        // always written under the server's configured output root so remote users
4984        // cannot direct writes to arbitrary filesystem paths.
4985        output_dir: if state.server_mode {
4986            None
4987        } else {
4988            form.output_dir.clone()
4989        },
4990        clones_dir: state.git_clones_dir.clone(),
4991        cocomo_mode: form
4992            .cocomo_mode
4993            .clone()
4994            .unwrap_or_else(|| "organic".to_string()),
4995        complexity_alert: form
4996            .complexity_alert
4997            .as_deref()
4998            .and_then(|s| s.parse::<u32>().ok())
4999            .unwrap_or(0),
5000        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5001    };
5002
5003    tokio::spawn(run_analysis_task(task));
5004
5005    let template = ScanWaitTemplate {
5006        version: env!("CARGO_PKG_VERSION"),
5007        wait_id_json,
5008        project_path: form.path.clone(),
5009        csp_nonce,
5010    };
5011    let html = template
5012        .render()
5013        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5014    let mut response = Html(html).into_response();
5015    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
5016        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
5017            response.headers_mut().insert(name, val);
5018        }
5019    }
5020    response
5021}
5022
5023struct AnalysisTask {
5024    sem_permit: tokio::sync::OwnedSemaphorePermit,
5025    state: AppState,
5026    wait_id: String,
5027    config: AppConfig,
5028    cancel: Arc<std::sync::atomic::AtomicBool>,
5029    phase: Arc<std::sync::Mutex<String>>,
5030    files_done: Arc<std::sync::atomic::AtomicUsize>,
5031    files_total: Arc<std::sync::atomic::AtomicUsize>,
5032    git_repo: Option<String>,
5033    git_ref: Option<String>,
5034    project_path: String,
5035    output_dir: Option<String>,
5036    clones_dir: PathBuf,
5037    cocomo_mode: String,
5038    complexity_alert: u32,
5039    exclude_duplicates: bool,
5040}
5041
5042#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
5043async fn run_analysis_task(task: AnalysisTask) {
5044    let _permit = task.sem_permit;
5045
5046    let cancel_sb = Arc::clone(&task.cancel);
5047    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5048    let clones_dir_sb = task.clones_dir;
5049    // Save the upload staging path before config is moved into spawn_blocking.
5050    let upload_staging_root = task
5051        .config
5052        .discovery
5053        .root_paths
5054        .first()
5055        .filter(|p| is_upload_tmp_path(p))
5056        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5057        .map(PathBuf::from);
5058    let config_sb = task.config;
5059    let progress_sb = sloc_core::ProgressCounters {
5060        files_done: Arc::clone(&task.files_done),
5061        files_total: Arc::clone(&task.files_total),
5062    };
5063    if let Ok(mut p) = task.phase.lock() {
5064        *p = "Scanning files".to_string();
5065    }
5066    let analysis_result = tokio::task::spawn_blocking(move || {
5067        run_analysis_blocking(
5068            config_sb,
5069            git_repo_sb,
5070            git_ref_sb,
5071            clones_dir_sb,
5072            cancel_sb,
5073            Some(progress_sb),
5074        )
5075    })
5076    .await
5077    .map_err(|err| anyhow::anyhow!(err.to_string()))
5078    .and_then(|result| result);
5079
5080    if let Ok(mut p) = task.phase.lock() {
5081        *p = "Writing reports".to_string();
5082    }
5083
5084    // If cancelled while running, discard results and mark as cancelled.
5085    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5086        let mut runs = task.state.async_runs.lock().await;
5087        // Only overwrite if still Running (don't clobber a Complete that snuck in).
5088        if matches!(
5089            runs.get(&task.wait_id),
5090            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5091        ) {
5092            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5093        }
5094        drop(runs);
5095        return;
5096    }
5097
5098    let run = match analysis_result {
5099        Ok(v) => v,
5100        Err(err) => {
5101            // Distinguish user-cancelled from real failure.
5102            if err.to_string().contains("analysis cancelled") {
5103                let mut runs = task.state.async_runs.lock().await;
5104                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5105                drop(runs);
5106                return;
5107            }
5108            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5109            let mut runs = task.state.async_runs.lock().await;
5110            runs.insert(
5111                task.wait_id.clone(),
5112                AsyncRunState::Failed {
5113                    message: "Analysis failed. Check that the path exists and is readable."
5114                        .to_string(),
5115                },
5116            );
5117            drop(runs);
5118            return;
5119        }
5120    };
5121
5122    let run_id = run.tool.run_id.clone();
5123    tracing::info!(event = "scan_complete", run_id = %run_id,
5124        path = %task.project_path, files = run.summary_totals.files_analyzed,
5125        "Analysis finished");
5126
5127    let prev_entry: Option<RegistryEntry> = {
5128        let reg = task.state.registry.lock().await;
5129        reg.entries_for_roots(&run.input_roots)
5130            .into_iter()
5131            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5132            .cloned()
5133    };
5134
5135    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5136        prev.json_path
5137            .as_ref()
5138            .and_then(|p| read_json(p).ok())
5139            .map(|prev_run| compute_delta(&prev_run, &run))
5140    });
5141    let prev_scan_count: usize = {
5142        let reg = task.state.registry.lock().await;
5143        reg.entries_for_roots(&run.input_roots)
5144            .iter()
5145            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5146            .count()
5147    };
5148
5149    // Build the HTML report now that delta is available, so the artifact
5150    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5151    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5152        .as_ref()
5153        .zip(prev_entry.as_ref())
5154        .map(|(cmp, prev)| ReportDeltaContext {
5155            delta_code_added: sum_added_code_lines(cmp),
5156            delta_code_removed: sum_removed_code_lines(cmp),
5157            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5158            delta_files_added: cmp.files_added,
5159            delta_files_removed: cmp.files_removed,
5160            delta_files_modified: cmp.files_modified,
5161            delta_files_unchanged: cmp.files_unchanged,
5162            prev_code_lines: prev.summary.code_lines,
5163            prev_scan_count: prev_scan_count + 1,
5164            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5165            prev_run_id: Some(prev.run_id.clone()),
5166            current_run_id: Some(run_id.clone()),
5167        });
5168    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5169        Ok(h) => h,
5170        Err(err) => {
5171            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5172            let mut runs = task.state.async_runs.lock().await;
5173            runs.insert(
5174                task.wait_id.clone(),
5175                AsyncRunState::Failed {
5176                    message: "Failed to render HTML report.".to_string(),
5177                },
5178            );
5179            drop(runs);
5180            return;
5181        }
5182    };
5183
5184    let output_root = resolve_output_root(task.output_dir.as_deref());
5185    let project_label = derive_project_label(
5186        task.git_repo.as_deref(),
5187        task.git_ref.as_deref(),
5188        &task.project_path,
5189    );
5190    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5191    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5192
5193    let result_context = RunResultContext {
5194        prev_entry: prev_entry.clone(),
5195        prev_scan_count,
5196        project_path: task.project_path.clone(),
5197        cocomo_mode: task.cocomo_mode.clone(),
5198        complexity_alert: task.complexity_alert,
5199        exclude_duplicates: task.exclude_duplicates,
5200    };
5201
5202    let artifact_result = persist_run_artifacts(
5203        &run,
5204        &report_html,
5205        &run_dir,
5206        &run.effective_configuration.reporting.report_title,
5207        &file_stem,
5208        result_context,
5209    );
5210
5211    let (artifacts, pending_pdf) = match artifact_result {
5212        Ok(v) => v,
5213        Err(err) => {
5214            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5215            let mut runs = task.state.async_runs.lock().await;
5216            runs.insert(
5217                task.wait_id.clone(),
5218                AsyncRunState::Failed {
5219                    message: "Failed to save report artifacts. Check available disk space."
5220                        .to_string(),
5221                },
5222            );
5223            drop(runs);
5224            return;
5225        }
5226    };
5227
5228    {
5229        let mut map = task.state.artifacts.lock().await;
5230        map.insert(run_id.clone(), artifacts.clone());
5231    }
5232
5233    {
5234        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5235        let mut reg = task.state.registry.lock().await;
5236        reg.add_entry(entry);
5237        let _ = reg.save(&task.state.registry_path);
5238    }
5239
5240    if let Some(ref cfg_path) = artifacts.scan_config_path {
5241        save_scan_config_json(
5242            cfg_path,
5243            &run,
5244            &task.project_path,
5245            task.output_dir.as_deref(),
5246            &task.cocomo_mode,
5247            task.complexity_alert,
5248            task.exclude_duplicates,
5249        );
5250    }
5251
5252    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5253
5254    prom_runs_total().inc();
5255
5256    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5257    let mut runs = task.state.async_runs.lock().await;
5258    runs.insert(
5259        task.wait_id.clone(),
5260        AsyncRunState::Complete {
5261            run_id: run_id.clone(),
5262        },
5263    );
5264    drop(runs);
5265
5266    // Remove the client-upload staging directory after a successful scan so
5267    // that uploaded project files don't accumulate in the OS temp directory.
5268    if let Some(staging) = upload_staging_root {
5269        let _ = tokio::fs::remove_dir_all(staging).await;
5270    }
5271
5272    let _ = scan_delta;
5273}
5274
5275fn save_scan_config_json(
5276    cfg_path: &std::path::Path,
5277    run: &sloc_core::AnalysisRun,
5278    project_path: &str,
5279    output_dir: Option<&str>,
5280    cocomo_mode: &str,
5281    complexity_alert: u32,
5282    exclude_duplicates: bool,
5283) {
5284    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5285        .ok()
5286        .and_then(|v| v.as_str().map(String::from))
5287        .unwrap_or_else(|| "code_only".to_string());
5288    let behavior_str =
5289        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5290            .ok()
5291            .and_then(|v| v.as_str().map(String::from))
5292            .unwrap_or_else(|| "skip".to_string());
5293    let continuation_policy_str = serde_json::to_value(
5294        run.effective_configuration
5295            .analysis
5296            .continuation_line_policy,
5297    )
5298    .ok()
5299    .and_then(|v| v.as_str().map(String::from))
5300    .unwrap_or_else(default_each_physical_line);
5301    let blank_policy_str = serde_json::to_value(
5302        run.effective_configuration
5303            .analysis
5304            .blank_in_block_comment_policy,
5305    )
5306    .ok()
5307    .and_then(|v| v.as_str().map(String::from))
5308    .unwrap_or_else(default_count_as_comment);
5309    let scan_cfg = ScanConfig {
5310        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5311        path: project_path.to_string(),
5312        include_globs: run
5313            .effective_configuration
5314            .discovery
5315            .include_globs
5316            .join("\n"),
5317        exclude_globs: run
5318            .effective_configuration
5319            .discovery
5320            .exclude_globs
5321            .join("\n"),
5322        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5323        mixed_line_policy: policy_str,
5324        python_docstrings_as_comments: run
5325            .effective_configuration
5326            .analysis
5327            .python_docstrings_as_comments,
5328        generated_file_detection: run
5329            .effective_configuration
5330            .analysis
5331            .generated_file_detection,
5332        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5333        vendor_directory_detection: run
5334            .effective_configuration
5335            .analysis
5336            .vendor_directory_detection,
5337        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5338        binary_file_behavior: behavior_str,
5339        output_dir: output_dir.unwrap_or("").to_string(),
5340        report_title: run.effective_configuration.reporting.report_title.clone(),
5341        continuation_line_policy: continuation_policy_str,
5342        blank_in_block_comment_policy: blank_policy_str,
5343        count_compiler_directives: run
5344            .effective_configuration
5345            .analysis
5346            .count_compiler_directives,
5347        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5348        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5349        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5350        style_lang_scope: run
5351            .effective_configuration
5352            .analysis
5353            .style_lang_scope
5354            .clone(),
5355        coverage_file: run
5356            .effective_configuration
5357            .analysis
5358            .coverage_file
5359            .as_ref()
5360            .map(|p| p.display().to_string())
5361            .unwrap_or_default(),
5362        cocomo_mode: cocomo_mode.to_string(),
5363        complexity_alert,
5364        exclude_duplicates,
5365        activity_window: run
5366            .effective_configuration
5367            .analysis
5368            .activity_window_days
5369            .unwrap_or(0),
5370    };
5371    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5372        let _ = std::fs::write(cfg_path, json);
5373    }
5374}
5375
5376#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5377fn run_analysis_blocking(
5378    mut config: AppConfig,
5379    git_repo: Option<String>,
5380    git_ref: Option<String>,
5381    clones_dir: PathBuf,
5382    cancel: Arc<std::sync::atomic::AtomicBool>,
5383    progress: Option<sloc_core::ProgressCounters>,
5384) -> Result<sloc_core::AnalysisRun> {
5385    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5386        let dest = git_clone_dest(&repo, &clones_dir);
5387        sloc_git::clone_or_fetch(&repo, &dest)?;
5388        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5389        sloc_git::create_worktree(&dest, &refname, &wt)?;
5390        config.discovery.root_paths = vec![wt.clone()];
5391        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5392        let _ = sloc_git::destroy_worktree(&dest, &wt);
5393        let mut run = run?;
5394        if run.git_branch.is_none() {
5395            run.git_branch = Some(refname);
5396        }
5397        return Ok(run);
5398    }
5399    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5400}
5401
5402fn derive_project_label(
5403    git_repo: Option<&str>,
5404    git_ref: Option<&str>,
5405    fallback_path: &str,
5406) -> String {
5407    match (
5408        git_repo.filter(|s| !s.is_empty()),
5409        git_ref.filter(|s| !s.is_empty()),
5410    ) {
5411        (Some(repo), Some(refname)) => {
5412            let repo_name = repo
5413                .trim_end_matches('/')
5414                .trim_end_matches(".git")
5415                .rsplit('/')
5416                .next()
5417                .unwrap_or("repo");
5418            sanitize_project_label(&format!("{repo_name}_{refname}"))
5419        }
5420        _ => sanitize_project_label(fallback_path),
5421    }
5422}
5423
5424fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5425    let commit = commit_short.unwrap_or("").trim();
5426    if commit.is_empty() {
5427        project_label.to_string()
5428    } else {
5429        format!("{project_label}_{commit}")
5430    }
5431}
5432
5433// ── Async scan status + result handlers ──────────────────────────────────────
5434
5435#[derive(Serialize)]
5436#[serde(tag = "state", rename_all = "snake_case")]
5437enum AsyncRunStatusResponse {
5438    Running {
5439        elapsed_secs: u64,
5440        phase: String,
5441        files_done: u64,
5442        files_total: u64,
5443    },
5444    Complete {
5445        run_id: String,
5446    },
5447    Failed {
5448        message: String,
5449    },
5450    Cancelled,
5451}
5452
5453async fn async_run_status_handler(
5454    State(state): State<AppState>,
5455    AxumPath(wait_id): AxumPath<String>,
5456) -> Response {
5457    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5458    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5459        return error::bad_request("invalid wait_id");
5460    }
5461    let run_state = {
5462        let runs = state.async_runs.lock().await;
5463        runs.get(&wait_id).cloned()
5464    };
5465    match run_state {
5466        None => error::not_found("run not found"),
5467        Some(AsyncRunState::Running {
5468            started_at,
5469            phase,
5470            files_done,
5471            files_total,
5472            ..
5473        }) => {
5474            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5475            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5476                let mut runs = state.async_runs.lock().await;
5477                runs.insert(
5478                    wait_id,
5479                    AsyncRunState::Failed {
5480                        message: "Analysis timed out after 2 hours.".to_string(),
5481                    },
5482                );
5483                drop(runs);
5484                return Json(AsyncRunStatusResponse::Failed {
5485                    message: "Analysis timed out after 2 hours.".to_string(),
5486                })
5487                .into_response();
5488            }
5489            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5490            Json(AsyncRunStatusResponse::Running {
5491                elapsed_secs: started_at.elapsed().as_secs(),
5492                phase: phase_str,
5493                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5494                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5495            })
5496            .into_response()
5497        }
5498        Some(AsyncRunState::Complete { run_id }) => {
5499            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5500        }
5501        Some(AsyncRunState::Failed { message }) => {
5502            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5503        }
5504        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5505    }
5506}
5507
5508async fn cancel_run_handler(
5509    State(state): State<AppState>,
5510    AxumPath(wait_id): AxumPath<String>,
5511) -> Response {
5512    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5513        return error::bad_request("invalid wait_id");
5514    }
5515    let mut runs = state.async_runs.lock().await;
5516    let resp = match runs.get(&wait_id) {
5517        Some(AsyncRunState::Running { cancel_token, .. }) => {
5518            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5519            runs.insert(wait_id, AsyncRunState::Cancelled);
5520            StatusCode::OK.into_response()
5521        }
5522        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5523        _ => error::not_found("run not found"),
5524    };
5525    drop(runs);
5526    resp
5527}
5528
5529async fn async_run_result_handler(
5530    State(state): State<AppState>,
5531    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5532    AxumPath(run_id): AxumPath<String>,
5533) -> Response {
5534    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5535        return StatusCode::BAD_REQUEST.into_response();
5536    }
5537
5538    let artifacts = {
5539        let map = state.artifacts.lock().await;
5540        map.get(&run_id).cloned()
5541    };
5542    let artifacts = if let Some(a) = artifacts {
5543        a
5544    } else {
5545        let reg = state.registry.lock().await;
5546        if let Some(entry) = reg.find_by_run_id(&run_id) {
5547            recover_artifacts_from_registry(entry)
5548        } else {
5549            let html = ErrorTemplate {
5550                message: format!(
5551                    "Report not found. Run ID {} is not in the scan history.",
5552                    &run_id[..run_id.len().min(8)]
5553                ),
5554                last_report_url: Some("/view-reports".to_string()),
5555                last_report_label: Some("View Reports".to_string()),
5556                run_id: Some(run_id.clone()),
5557                error_code: Some(404),
5558                csp_nonce: csp_nonce.clone(),
5559                version: env!("CARGO_PKG_VERSION"),
5560            }
5561            .render()
5562            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5563            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5564        }
5565    };
5566
5567    let json_path = if let Some(p) = &artifacts.json_path {
5568        p.clone()
5569    } else {
5570        let html = ErrorTemplate {
5571            message: "JSON result was not saved for this run.".to_string(),
5572            last_report_url: Some("/view-reports".to_string()),
5573            last_report_label: Some("View Reports".to_string()),
5574            run_id: Some(run_id.clone()),
5575            error_code: Some(404),
5576            csp_nonce: csp_nonce.clone(),
5577            version: env!("CARGO_PKG_VERSION"),
5578        }
5579        .render()
5580        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5581        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5582    };
5583
5584    let Ok(run) = read_json(&json_path) else {
5585        let folder_hint = output_folder_hint(&json_path);
5586        let redirect_url = format!("/runs/result/{run_id}");
5587        return missing_scan_relocate_response(
5588            &format!(
5589                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5590                 deleted. Browse to the folder containing your scan output to reconnect it.",
5591                json_path.display()
5592            ),
5593            &run_id,
5594            &folder_hint,
5595            &redirect_url,
5596            state.server_mode,
5597            &csp_nonce,
5598        );
5599    };
5600
5601    let confluence_configured = {
5602        let store = state.confluence.lock().await;
5603        store.is_configured()
5604    };
5605
5606    render_result_page(
5607        &run,
5608        &artifacts,
5609        &run_id,
5610        &csp_nonce,
5611        confluence_configured,
5612        state.server_mode,
5613    )
5614}
5615
5616/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5617fn json_escape(s: &str) -> String {
5618    s.replace('\\', "\\\\").replace('"', "\\\"")
5619}
5620
5621/// Per-language line/symbol totals summed across every language in a run.
5622struct LangTotals {
5623    physical_lines: u64,
5624    code_lines: u64,
5625    comment_lines: u64,
5626    blank_lines: u64,
5627    mixed_lines: u64,
5628    functions: u64,
5629    classes: u64,
5630    variables: u64,
5631    imports: u64,
5632}
5633
5634fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5635    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5636        run.totals_by_language.iter().map(f).sum()
5637    };
5638    LangTotals {
5639        physical_lines: s(|r| r.total_physical_lines),
5640        code_lines: s(|r| r.code_lines),
5641        comment_lines: s(|r| r.comment_lines),
5642        blank_lines: s(|r| r.blank_lines),
5643        mixed_lines: s(|r| r.mixed_lines_separate),
5644        functions: s(|r| r.functions),
5645        classes: s(|r| r.classes),
5646        variables: s(|r| r.variables),
5647        imports: s(|r| r.imports),
5648    }
5649}
5650
5651/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5652struct DeltaFields {
5653    prev_fa_str: String,
5654    prev_fs_str: String,
5655    prev_pl_str: String,
5656    prev_cl_str: String,
5657    prev_cml_str: String,
5658    prev_bl_str: String,
5659    delta_fa_str: String,
5660    delta_fa_class: String,
5661    delta_fs_str: String,
5662    delta_fs_class: String,
5663    delta_pl_str: String,
5664    delta_pl_class: String,
5665    delta_cl_str: String,
5666    delta_cl_class: String,
5667    delta_cml_str: String,
5668    delta_cml_class: String,
5669    delta_bl_str: String,
5670    delta_bl_class: String,
5671    delta_lines_added: Option<i64>,
5672    delta_lines_removed: Option<i64>,
5673    delta_lines_net_str: String,
5674    delta_lines_net_class: String,
5675}
5676
5677// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5678// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5679// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5680// from those field names and obscure the 1:1 mapping.
5681#[allow(
5682    clippy::similar_names,
5683    reason = "locals mirror template-bound struct fields"
5684)]
5685fn compute_delta_fields(
5686    prev_entry: Option<&RegistryEntry>,
5687    totals: &LangTotals,
5688    files_analyzed: u64,
5689    files_skipped: u64,
5690    scan_delta: Option<&sloc_core::ScanComparison>,
5691) -> DeltaFields {
5692    let prev_sum = prev_entry.map(|e| &e.summary);
5693    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5694
5695    let (delta_fa_str, delta_fa_class) =
5696        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5697    let (delta_fs_str, delta_fs_class) =
5698        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5699    let (delta_pl_str, delta_pl_class) = summary_delta(
5700        totals.physical_lines,
5701        prev_sum.map(|s| s.total_physical_lines),
5702    );
5703    let (delta_cl_str, delta_cl_class) =
5704        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5705    let (delta_cml_str, delta_cml_class) =
5706        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5707    let (delta_bl_str, delta_bl_class) =
5708        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5709
5710    let delta_lines_added = scan_delta.map(sum_added_code_lines);
5711    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5712    let (delta_lines_net_str, delta_lines_net_class) =
5713        match (delta_lines_added, delta_lines_removed) {
5714            (Some(a), Some(r)) => {
5715                let net = a - r;
5716                (fmt_delta(net), delta_class(net).to_string())
5717            }
5718            _ => ("\u{2014}".to_string(), "na".to_string()),
5719        };
5720
5721    DeltaFields {
5722        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5723        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5724        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5725        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5726        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5727        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5728        delta_fa_str,
5729        delta_fa_class: delta_fa_class.to_string(),
5730        delta_fs_str,
5731        delta_fs_class: delta_fs_class.to_string(),
5732        delta_pl_str,
5733        delta_pl_class: delta_pl_class.to_string(),
5734        delta_cl_str,
5735        delta_cl_class: delta_cl_class.to_string(),
5736        delta_cml_str,
5737        delta_cml_class: delta_cml_class.to_string(),
5738        delta_bl_str,
5739        delta_bl_class: delta_bl_class.to_string(),
5740        delta_lines_added,
5741        delta_lines_removed,
5742        delta_lines_net_str,
5743        delta_lines_net_class,
5744    }
5745}
5746
5747/// Count of unchanged code lines in a scan comparison.
5748fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
5749    scan_delta
5750        .file_deltas
5751        .iter()
5752        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
5753        .map(|f| {
5754            #[allow(clippy::cast_sign_loss)]
5755            let n = f.current_code as u64;
5756            n
5757        })
5758        .sum()
5759}
5760
5761fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
5762    run.git_remote_url
5763        .as_deref()
5764        .zip(run.git_commit_long.as_deref())
5765        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
5766}
5767
5768fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
5769    run.git_remote_url
5770        .as_deref()
5771        .zip(run.git_branch.as_deref())
5772        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
5773}
5774
5775fn scan_performed_by(run: &AnalysisRun) -> String {
5776    run.environment.ci_name.clone().unwrap_or_else(|| {
5777        format!(
5778            "{} / {}",
5779            run.environment.initiator_username, run.environment.initiator_hostname
5780        )
5781    })
5782}
5783
5784/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
5785fn build_lang_chart_json(run: &AnalysisRun) -> String {
5786    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
5787    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
5788    let entries: Vec<String> = langs
5789        .into_iter()
5790        .take(12)
5791        .map(|l| {
5792            let name = json_escape(l.language.display_name());
5793            format!(
5794                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
5795                name,
5796                l.code_lines,
5797                l.comment_lines,
5798                l.blank_lines,
5799                l.total_physical_lines,
5800                l.functions,
5801                l.classes,
5802                l.variables,
5803                l.imports,
5804                l.files,
5805            )
5806        })
5807        .collect();
5808    format!("[{}]", entries.join(","))
5809}
5810
5811/// Per-language files-vs-lines points as a JSON array for the scatter chart.
5812fn build_scatter_chart_json(run: &AnalysisRun) -> String {
5813    let entries: Vec<String> = run
5814        .totals_by_language
5815        .iter()
5816        .map(|l| {
5817            let name = json_escape(l.language.display_name());
5818            format!(
5819                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
5820                name, l.files, l.code_lines, l.total_physical_lines,
5821            )
5822        })
5823        .collect();
5824    format!("[{}]", entries.join(","))
5825}
5826
5827/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
5828fn build_semantic_chart_json(run: &AnalysisRun) -> String {
5829    let entries: Vec<String> = run
5830        .totals_by_language
5831        .iter()
5832        .filter(|l| {
5833            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
5834        })
5835        .map(|l| {
5836            let name = json_escape(l.language.display_name());
5837            format!(
5838                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
5839                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
5840            )
5841        })
5842        .collect();
5843    format!("[{}]", entries.join(","))
5844}
5845
5846/// Per-submodule line counts as a JSON array for the submodule chart.
5847fn build_submodule_chart_json(run: &AnalysisRun) -> String {
5848    let entries: Vec<String> = run
5849        .submodule_summaries
5850        .iter()
5851        .map(|s| {
5852            let name = json_escape(&s.name);
5853            format!(
5854                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
5855                name,
5856                s.code_lines,
5857                s.comment_lines,
5858                s.blank_lines,
5859                s.total_physical_lines,
5860                s.files_analyzed,
5861            )
5862        })
5863        .collect();
5864    format!("[{}]", entries.join(","))
5865}
5866
5867/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
5868#[allow(clippy::cast_precision_loss)]
5869fn cov_pct_str(hit: u64, found: u64) -> String {
5870    if found > 0 {
5871        format!("{:.1}", hit as f64 / found as f64 * 100.0)
5872    } else {
5873        String::new()
5874    }
5875}
5876
5877/// `hit / found` summary string, or empty when nothing was found.
5878fn cov_lines_summary_str(hit: u64, found: u64) -> String {
5879    if found > 0 {
5880        format!("{hit} / {found}")
5881    } else {
5882        String::new()
5883    }
5884}
5885
5886const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
5887    use sloc_core::CocomoMode;
5888    match mode {
5889        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
5890        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
5891        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
5892    }
5893}
5894
5895const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
5896    use sloc_core::CocomoMode;
5897    match mode {
5898        CocomoMode::Organic => "Organic",
5899        CocomoMode::SemiDetached => "Semi-detached",
5900        CocomoMode::Embedded => "Embedded",
5901    }
5902}
5903
5904const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
5905    use sloc_core::CocomoMode;
5906    match mode {
5907        CocomoMode::Organic => {
5908            "Organic: A small team working on a well-understood project in a familiar \
5909             environment with minimal external constraints. Suited for internal tools, \
5910             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
5911        }
5912        CocomoMode::SemiDetached => {
5913            "Semi-detached: A mixed team with varying experience tackling a project with \
5914             moderate novelty and some rigid constraints. Typical for compilers, transaction \
5915             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
5916        }
5917        CocomoMode::Embedded => {
5918            "Embedded: Tight hardware, software, or operational constraints requiring \
5919             significant innovation and deep integration work. Typical for real-time control \
5920             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
5921        }
5922    }
5923}
5924
5925/// COCOMO display strings recomputed for the scan-wizard-selected mode.
5926struct CocomoFields {
5927    has_cocomo: bool,
5928    effort_str: String,
5929    duration_str: String,
5930    staff_str: String,
5931    ksloc_str: String,
5932    mode_label: String,
5933    mode_tooltip: String,
5934}
5935
5936#[allow(clippy::cast_precision_loss)]
5937fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
5938    use sloc_core::CocomoMode;
5939    let mode = match mode_str {
5940        "semi_detached" => CocomoMode::SemiDetached,
5941        "embedded" => CocomoMode::Embedded,
5942        _ => CocomoMode::Organic,
5943    };
5944    let (a, b, c, d) = cocomo_coefficients(mode);
5945    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
5946    let effort = a * ksloc.powf(b);
5947    let duration = c * effort.powf(d);
5948    let staff = if duration > 0.0 {
5949        effort / duration
5950    } else {
5951        0.0
5952    };
5953    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
5954    let mode_label = cocomo_mode_label(mode).to_string();
5955    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
5956    if run.summary_totals.code_lines > 0 {
5957        CocomoFields {
5958            has_cocomo: true,
5959            effort_str: round2(effort),
5960            duration_str: round2(duration),
5961            staff_str: round2(staff),
5962            ksloc_str: round2(ksloc),
5963            mode_label,
5964            mode_tooltip,
5965        }
5966    } else {
5967        CocomoFields {
5968            has_cocomo: false,
5969            effort_str: String::new(),
5970            duration_str: String::new(),
5971            staff_str: String::new(),
5972            ksloc_str: String::new(),
5973            mode_label,
5974            mode_tooltip,
5975        }
5976    }
5977}
5978
5979#[allow(clippy::too_many_lines)]
5980#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
5981#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
5982fn render_result_page(
5983    run: &AnalysisRun,
5984    artifacts: &RunArtifacts,
5985    run_id: &str,
5986    csp_nonce: &str,
5987    confluence_configured: bool,
5988    server_mode: bool,
5989) -> Response {
5990    let ctx = &artifacts.result_context;
5991    let prev_entry = &ctx.prev_entry;
5992    let prev_scan_count = ctx.prev_scan_count;
5993    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
5994    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
5995    // field is never blank.
5996    let project_path_owned = if ctx.project_path.is_empty() {
5997        run.input_roots.join(", ")
5998    } else {
5999        ctx.project_path.clone()
6000    };
6001    let project_path = &project_path_owned;
6002
6003    let scan_delta = prev_entry.as_ref().and_then(|prev| {
6004        prev.json_path
6005            .as_ref()
6006            .and_then(|p| read_json(p).ok())
6007            .map(|prev_run| compute_delta(&prev_run, run))
6008    });
6009
6010    let files_analyzed = run.per_file_records.len() as u64;
6011    let files_skipped = run.skipped_file_records.len() as u64;
6012    let totals = sum_lang_totals(run);
6013
6014    let DeltaFields {
6015        prev_fa_str,
6016        prev_fs_str,
6017        prev_pl_str,
6018        prev_cl_str,
6019        prev_cml_str,
6020        prev_bl_str,
6021        delta_fa_str,
6022        delta_fa_class,
6023        delta_fs_str,
6024        delta_fs_class,
6025        delta_pl_str,
6026        delta_pl_class,
6027        delta_cl_str,
6028        delta_cl_class,
6029        delta_cml_str,
6030        delta_cml_class,
6031        delta_bl_str,
6032        delta_bl_class,
6033        delta_lines_added,
6034        delta_lines_removed,
6035        delta_lines_net_str,
6036        delta_lines_net_class,
6037    } = compute_delta_fields(
6038        prev_entry.as_ref(),
6039        &totals,
6040        files_analyzed,
6041        files_skipped,
6042        scan_delta.as_ref(),
6043    );
6044
6045    let run_dir = artifacts.output_dir.clone();
6046    let git_branch = run.git_branch.clone();
6047    let git_commit = run.git_commit_short.clone();
6048    let git_commit_long = run.git_commit_long.clone();
6049    let git_author = run.git_commit_author.clone();
6050    let git_commit_url = git_commit_url_for(run);
6051    let git_branch_url = git_branch_url_for(run);
6052    let scan_performed_by = scan_performed_by(run);
6053    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6054    let os_display = format!(
6055        "{} / {}",
6056        run.environment.operating_system, run.environment.architecture
6057    );
6058    let test_count = run.summary_totals.test_count;
6059
6060    // ── New metrics ──────────────────────────────────────────────────────────
6061    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6062    let lsloc = run.summary_totals.lsloc;
6063    let uloc = run.uloc;
6064    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6065    let duplicate_group_count = run.duplicate_groups.len();
6066
6067    // Re-compute COCOMO with the mode selected in the scan wizard.
6068    let ctx = &artifacts.result_context;
6069    let CocomoFields {
6070        has_cocomo,
6071        effort_str: cocomo_effort_str,
6072        duration_str: cocomo_duration_str,
6073        staff_str: cocomo_staff_str,
6074        ksloc_str: cocomo_ksloc_str,
6075        mode_label: cocomo_mode_label,
6076        mode_tooltip: cocomo_mode_tooltip,
6077    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6078    let complexity_alert = ctx.complexity_alert;
6079
6080    let template = ResultTemplate {
6081        version: env!("CARGO_PKG_VERSION"),
6082        report_title: run.effective_configuration.reporting.report_title.clone(),
6083        project_path: project_path.clone(),
6084        output_dir: display_path(&artifacts.output_dir),
6085        run_id: run_id.to_owned(),
6086        run_id_short: run_id
6087            .split('-')
6088            .next_back()
6089            .unwrap_or(run_id)
6090            .chars()
6091            .take(7)
6092            .collect(),
6093        files_analyzed,
6094        files_skipped,
6095        physical_lines: totals.physical_lines,
6096        code_lines: totals.code_lines,
6097        comment_lines: totals.comment_lines,
6098        blank_lines: totals.blank_lines,
6099        mixed_lines: totals.mixed_lines,
6100        functions: totals.functions,
6101        classes: totals.classes,
6102        variables: totals.variables,
6103        imports: totals.imports,
6104        html_url: artifacts
6105            .html_path
6106            .as_ref()
6107            .map(|_| format!("/runs/html/{run_id}")),
6108        pdf_url: artifacts
6109            .pdf_path
6110            .as_ref()
6111            .map(|_| format!("/runs/pdf/{run_id}")),
6112        json_url: artifacts
6113            .json_path
6114            .as_ref()
6115            .map(|_| format!("/runs/json/{run_id}")),
6116        html_download_url: artifacts
6117            .html_path
6118            .as_ref()
6119            .map(|_| format!("/runs/html/{run_id}?download=1")),
6120        pdf_download_url: artifacts
6121            .pdf_path
6122            .as_ref()
6123            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6124        json_download_url: artifacts
6125            .json_path
6126            .as_ref()
6127            .map(|_| format!("/runs/json/{run_id}?download=1")),
6128        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6129        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6130        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6131        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6132        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6133        prev_fa_str,
6134        prev_fs_str,
6135        prev_pl_str,
6136        prev_cl_str,
6137        prev_cml_str,
6138        prev_bl_str,
6139        delta_fa_str,
6140        delta_fa_class,
6141        delta_fs_str,
6142        delta_fs_class,
6143        delta_pl_str,
6144        delta_pl_class,
6145        delta_cl_str,
6146        delta_cl_class,
6147        delta_cml_str,
6148        delta_cml_class,
6149        delta_bl_str,
6150        delta_bl_class,
6151        delta_lines_added,
6152        delta_lines_removed,
6153        delta_lines_net_str,
6154        delta_lines_net_class,
6155        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6156        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6157        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6158        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6159        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6160        git_branch,
6161        git_branch_url,
6162        git_commit,
6163        git_commit_long,
6164        git_author,
6165        git_commit_url,
6166        scan_performed_by,
6167        scan_time_display,
6168        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6169        os_display,
6170        test_count,
6171        test_assertion_count: run.summary_totals.test_assertion_count,
6172        current_scan_number: prev_scan_count + 1,
6173        prev_scan_count,
6174        submodule_rows: run
6175            .submodule_summaries
6176            .iter()
6177            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6178            .collect(),
6179        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6180        scan_config_url: format!("/runs/scan-config/{run_id}"),
6181        lang_chart_json: build_lang_chart_json(run),
6182        scatter_chart_json: build_scatter_chart_json(run),
6183        semantic_chart_json: build_semantic_chart_json(run),
6184        submodule_chart_json: build_submodule_chart_json(run),
6185        has_submodule_data: !run.submodule_summaries.is_empty(),
6186        has_semantic_data: run
6187            .totals_by_language
6188            .iter()
6189            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6190        csp_nonce: csp_nonce.to_owned(),
6191        confluence_configured,
6192        server_mode,
6193        report_header_footer: run
6194            .effective_configuration
6195            .reporting
6196            .report_header_footer
6197            .clone(),
6198        is_offline: false,
6199        cyclomatic_complexity,
6200        lsloc,
6201        uloc,
6202        dryness_pct_str,
6203        duplicate_group_count,
6204        has_cocomo,
6205        cocomo_effort_str,
6206        cocomo_duration_str,
6207        cocomo_staff_str,
6208        cocomo_ksloc_str,
6209        cocomo_mode_label,
6210        cocomo_mode_tooltip,
6211        complexity_alert,
6212        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6213        cov_line_pct: cov_pct_str(
6214            run.summary_totals.coverage_lines_hit,
6215            run.summary_totals.coverage_lines_found,
6216        ),
6217        cov_fn_pct: cov_pct_str(
6218            run.summary_totals.coverage_functions_hit,
6219            run.summary_totals.coverage_functions_found,
6220        ),
6221        cov_branch_pct: cov_pct_str(
6222            run.summary_totals.coverage_branches_hit,
6223            run.summary_totals.coverage_branches_found,
6224        ),
6225        cov_lines_summary: cov_lines_summary_str(
6226            run.summary_totals.coverage_lines_hit,
6227            run.summary_totals.coverage_lines_found,
6228        ),
6229    };
6230
6231    Html(
6232        template
6233            .render()
6234            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6235    )
6236    .into_response()
6237}
6238
6239fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6240    let slug: String = report_title
6241        .chars()
6242        .map(|c| {
6243            if c.is_alphanumeric() || c == '-' {
6244                c.to_ascii_lowercase()
6245            } else {
6246                '_'
6247            }
6248        })
6249        .collect::<String>()
6250        .split('_')
6251        .filter(|s| !s.is_empty())
6252        .collect::<Vec<_>>()
6253        .join("_");
6254
6255    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6256
6257    if slug.is_empty() {
6258        format!("report_{short_id}.pdf")
6259    } else {
6260        format!("{slug}_{short_id}.pdf")
6261    }
6262}
6263
6264#[derive(Serialize)]
6265struct PdfStatusResponse {
6266    ready: bool,
6267}
6268
6269/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6270/// Clients poll this to update the button state without page reloads.
6271async fn pdf_status_handler(
6272    State(state): State<AppState>,
6273    AxumPath(run_id): AxumPath<String>,
6274) -> Response {
6275    let pdf_path = {
6276        let registry = state.artifacts.lock().await;
6277        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6278    };
6279    let pdf_path = if pdf_path.is_some() {
6280        pdf_path
6281    } else {
6282        let reg = state.registry.lock().await;
6283        reg.find_by_run_id(&run_id)
6284            .map(recover_artifacts_from_registry)
6285            .and_then(|a| a.pdf_path)
6286    };
6287    let ready = pdf_path.is_some_and(|p| p.exists());
6288    Json(PdfStatusResponse { ready }).into_response()
6289}
6290
6291/// GET /`api/runs/:run_id/bundle`
6292///
6293/// Streams a gzip-compressed tar archive containing every artifact in the run's
6294/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6295/// is built in memory so it never touches a temp file.
6296async fn download_bundle_handler(
6297    State(state): State<AppState>,
6298    AxumPath(run_id): AxumPath<String>,
6299) -> Response {
6300    // Resolve output directory from in-memory cache or persisted registry.
6301    let output_dir = {
6302        let cache = state.artifacts.lock().await;
6303        cache.get(&run_id).map(|a| a.output_dir.clone())
6304    };
6305    let output_dir = if let Some(d) = output_dir {
6306        d
6307    } else {
6308        let reg = state.registry.lock().await;
6309        match reg.find_by_run_id(&run_id) {
6310            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6311            None => {
6312                return (
6313                    StatusCode::NOT_FOUND,
6314                    Json(serde_json::json!({"error": "Run not found"})),
6315                )
6316                    .into_response();
6317            }
6318        }
6319    };
6320
6321    if !output_dir.exists() {
6322        return (
6323            StatusCode::NOT_FOUND,
6324            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6325        )
6326            .into_response();
6327    }
6328
6329    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6330    let run_id_clone = run_id.clone();
6331    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6332        use flate2::{write::GzEncoder, Compression};
6333        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6334        {
6335            let mut tar = tar::Builder::new(&mut enc);
6336            tar.follow_symlinks(false);
6337            // Append every regular file in the output directory, skipping
6338            // sub-directories (the output dir is always flat).
6339            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6340                for entry in entries.filter_map(Result::ok) {
6341                    let p = entry.path();
6342                    if p.is_file() {
6343                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6344                        let archive_path = format!("{run_id_clone}/{name}");
6345                        tar.append_path_with_name(&p, &archive_path)?;
6346                    }
6347                }
6348            }
6349            tar.finish()?;
6350        }
6351        Ok(enc.finish()?)
6352    })
6353    .await;
6354
6355    match archive_result {
6356        Ok(Ok(bytes)) => {
6357            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6358            axum::response::Response::builder()
6359                .status(StatusCode::OK)
6360                .header("Content-Type", "application/gzip")
6361                .header(
6362                    "Content-Disposition",
6363                    format!("attachment; filename=\"{filename}\""),
6364                )
6365                .header("Content-Length", bytes.len().to_string())
6366                .body(axum::body::Body::from(bytes))
6367                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6368        }
6369        Ok(Err(e)) => (
6370            StatusCode::INTERNAL_SERVER_ERROR,
6371            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6372        )
6373            .into_response(),
6374        Err(e) => (
6375            StatusCode::INTERNAL_SERVER_ERROR,
6376            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6377        )
6378            .into_response(),
6379    }
6380}
6381
6382/// DELETE /`api/runs/:run_id`
6383///
6384/// Removes all on-disk artifacts for the run and purges the run from the
6385/// in-memory cache and the persisted registry. Returns 204 on success.
6386async fn delete_run_handler(
6387    State(state): State<AppState>,
6388    AxumPath(run_id): AxumPath<String>,
6389) -> Response {
6390    // Resolve output directory.
6391    let output_dir = {
6392        let mut cache = state.artifacts.lock().await;
6393        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6394        cache.remove(&run_id);
6395        dir
6396    };
6397    let output_dir = if let Some(d) = output_dir {
6398        d
6399    } else {
6400        let reg = state.registry.lock().await;
6401        reg.find_by_run_id(&run_id)
6402            .map(|e| recover_artifacts_from_registry(e).output_dir)
6403            .unwrap_or_default()
6404    };
6405
6406    // Remove from persisted registry.
6407    {
6408        let mut reg = state.registry.lock().await;
6409        reg.entries.retain(|e| e.run_id != run_id);
6410        let _ = reg.save(&state.registry_path);
6411    }
6412
6413    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6414    // a prior delete may have already removed the directory.
6415    if output_dir.exists() {
6416        match tokio::fs::remove_dir_all(&output_dir).await {
6417            Ok(()) => {}
6418            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6419            Err(e) => {
6420                return (
6421                    StatusCode::INTERNAL_SERVER_ERROR,
6422                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6423                )
6424                    .into_response();
6425            }
6426        }
6427    }
6428
6429    StatusCode::NO_CONTENT.into_response()
6430}
6431
6432/// POST /api/runs/cleanup
6433///
6434/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6435/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6436async fn cleanup_runs_handler(
6437    State(state): State<AppState>,
6438    Json(body): Json<serde_json::Value>,
6439) -> Response {
6440    let days = body
6441        .get("older_than_days")
6442        .and_then(serde_json::Value::as_u64)
6443        .unwrap_or(30)
6444        .max(1);
6445
6446    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6447
6448    // Collect expired entries from the registry.
6449    let expired: Vec<(String, PathBuf)> = {
6450        let reg = state.registry.lock().await;
6451        reg.entries
6452            .iter()
6453            .filter(|e| e.timestamp_utc < cutoff)
6454            .map(|e| {
6455                let arts = recover_artifacts_from_registry(e);
6456                (e.run_id.clone(), arts.output_dir)
6457            })
6458            .collect()
6459    };
6460
6461    let mut deleted = 0usize;
6462    for (run_id, output_dir) in &expired {
6463        // Remove from in-memory cache.
6464        state.artifacts.lock().await.remove(run_id);
6465        // Delete on-disk artifacts (non-fatal if already gone).
6466        if output_dir.exists() {
6467            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6468                eprintln!(
6469                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6470                    output_dir.display()
6471                );
6472                continue;
6473            }
6474        }
6475        deleted += 1;
6476    }
6477
6478    // Purge expired run IDs from the registry in one pass.
6479    let expired_ids: std::collections::HashSet<&str> =
6480        expired.iter().map(|(id, _)| id.as_str()).collect();
6481    {
6482        let mut reg = state.registry.lock().await;
6483        reg.entries
6484            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6485        let _ = reg.save(&state.registry_path);
6486    }
6487
6488    Json(serde_json::json!({ "deleted": deleted })).into_response()
6489}
6490
6491/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6492/// abort it when the policy is updated or disabled.
6493fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6494    tokio::spawn(async move {
6495        loop {
6496            let interval_secs = {
6497                let store = state.cleanup_policy.lock().await;
6498                match &store.policy {
6499                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6500                    _ => break,
6501                }
6502            };
6503            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6504            let n = run_auto_cleanup(&state).await;
6505            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6506        }
6507    })
6508}
6509
6510fn collect_runs_to_delete(
6511    reg: &ScanRegistry,
6512    max_age_days: Option<u32>,
6513    max_run_count: Option<u32>,
6514) -> std::collections::HashSet<String> {
6515    let mut to_delete = std::collections::HashSet::new();
6516    if let Some(days) = max_age_days {
6517        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6518        for e in &reg.entries {
6519            if e.timestamp_utc < cutoff {
6520                to_delete.insert(e.run_id.clone());
6521            }
6522        }
6523    }
6524    if let Some(max_count) = max_run_count {
6525        // entries are sorted newest-first; skip the ones we keep
6526        for e in reg.entries.iter().skip(max_count as usize) {
6527            to_delete.insert(e.run_id.clone());
6528        }
6529    }
6530    to_delete
6531}
6532
6533async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6534    let output_dir = {
6535        let mut cache = state.artifacts.lock().await;
6536        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6537        cache.remove(run_id);
6538        d
6539    };
6540    let output_dir = if let Some(d) = output_dir {
6541        d
6542    } else {
6543        let reg = state.registry.lock().await;
6544        reg.find_by_run_id(run_id)
6545            .map(|e| recover_artifacts_from_registry(e).output_dir)
6546            .unwrap_or_default()
6547    };
6548    if output_dir.exists() {
6549        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6550    }
6551}
6552
6553/// Core cleanup logic shared by the background task and the "Run Now" handler.
6554/// Applies both the age limit and the count limit, then updates `last_run_at`.
6555/// Returns the number of runs deleted.
6556async fn run_auto_cleanup(state: &AppState) -> u32 {
6557    let (max_age_days, max_run_count) = {
6558        let store = state.cleanup_policy.lock().await;
6559        match &store.policy {
6560            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6561            _ => return 0,
6562        }
6563    };
6564
6565    let to_delete = {
6566        let reg = state.registry.lock().await;
6567        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6568    };
6569
6570    for run_id in &to_delete {
6571        delete_run_artifacts(state, run_id).await;
6572    }
6573
6574    // Purge from registry.
6575    if !to_delete.is_empty() {
6576        let mut reg = state.registry.lock().await;
6577        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6578        let _ = reg.save(&state.registry_path);
6579    }
6580
6581    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6582    {
6583        let mut store = state.cleanup_policy.lock().await;
6584        store.last_run_at = Some(chrono::Utc::now());
6585        store.last_run_deleted = Some(deleted);
6586        let _ = store.save(&state.cleanup_policy_path);
6587    }
6588    deleted
6589}
6590
6591// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6592
6593/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6594async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6595    let store = state.cleanup_policy.lock().await;
6596    Json(serde_json::json!({
6597        "policy": store.policy,
6598        "last_run_at": store.last_run_at,
6599        "last_run_deleted": store.last_run_deleted,
6600    }))
6601    .into_response()
6602}
6603
6604/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6605async fn api_save_cleanup_policy(
6606    State(state): State<AppState>,
6607    Json(body): Json<CleanupPolicy>,
6608) -> Response {
6609    // Abort any running task so the new interval takes effect immediately.
6610    {
6611        let mut handle = state.cleanup_task_handle.lock().await;
6612        if let Some(h) = handle.take() {
6613            h.abort();
6614        }
6615    }
6616    {
6617        let mut store = state.cleanup_policy.lock().await;
6618        store.policy = Some(body.clone());
6619        if let Err(e) = store.save(&state.cleanup_policy_path) {
6620            return (
6621                StatusCode::INTERNAL_SERVER_ERROR,
6622                Json(serde_json::json!({"error": e.to_string()})),
6623            )
6624                .into_response();
6625        }
6626    }
6627    if body.enabled {
6628        let handle = spawn_cleanup_policy_task(state.clone());
6629        *state.cleanup_task_handle.lock().await = Some(handle);
6630    }
6631    StatusCode::NO_CONTENT.into_response()
6632}
6633
6634/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6635async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6636    let deleted = run_auto_cleanup(&state).await;
6637    Json(serde_json::json!({ "deleted": deleted })).into_response()
6638}
6639
6640/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6641async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6642    {
6643        let mut handle = state.cleanup_task_handle.lock().await;
6644        if let Some(h) = handle.take() {
6645            h.abort();
6646        }
6647    }
6648    {
6649        let mut store = state.cleanup_policy.lock().await;
6650        store.policy = None;
6651        let _ = store.save(&state.cleanup_policy_path);
6652    }
6653    StatusCode::NO_CONTENT.into_response()
6654}
6655
6656/// Serve the HTML artifact for a run — view or download.
6657/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6658/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6659/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6660/// Only called for browser views; downloads keep the self-contained inline version.
6661fn swap_inline_chart_js_for_static(html: String) -> String {
6662    let Some(head_end) = html.find("</head>") else {
6663        return html;
6664    };
6665    let Some(script_start) = html[..head_end].rfind("<script") else {
6666        return html;
6667    };
6668    let Some(close_offset) = html[script_start..].find("</script>") else {
6669        return html;
6670    };
6671    let block_end = script_start + close_offset + "</script>".len();
6672    format!(
6673        "{}<script src=\"/static/chart-report.js\"></script>{}",
6674        &html[..script_start],
6675        &html[block_end..]
6676    )
6677}
6678
6679/// current-request Content-Security-Policy nonce check.
6680fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6681    // Find the first nonce value that was baked in at render time.
6682    let Some(start) = html.find("nonce=\"") else {
6683        // Reports generated before nonce support was added have bare <style> and <script>
6684        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
6685        // the inline blocks — without it the browser blocks all CSS and JS.
6686        return html
6687            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6688            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6689    };
6690    let value_start = start + 7; // len(r#"nonce=""#) == 7
6691    let Some(end_offset) = html[value_start..].find('"') else {
6692        return html.to_owned();
6693    };
6694    let old_nonce = &html[value_start..value_start + end_offset];
6695    html.replace(
6696        &format!("nonce=\"{old_nonce}\""),
6697        &format!("nonce=\"{new_nonce}\""),
6698    )
6699}
6700
6701fn serve_html_artifact(
6702    path: &Path,
6703    wants_download: bool,
6704    csp_nonce: &str,
6705    run_id: &str,
6706    server_mode: bool,
6707) -> Response {
6708    match fs::read_to_string(path) {
6709        Ok(raw) => {
6710            // Patch the saved nonce so inline styles/scripts pass CSP.
6711            let content = patch_html_nonce(&raw, csp_nonce);
6712            if wants_download {
6713                // Keep the self-contained inline version for downloads (opened as file://).
6714                (
6715                    [
6716                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6717                        (
6718                            header::CONTENT_DISPOSITION,
6719                            "attachment; filename=report.html",
6720                        ),
6721                    ],
6722                    content,
6723                )
6724                    .into_response()
6725            } else {
6726                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
6727                // browser caches it after the first view; the HTML response also shrinks.
6728                Html(swap_inline_chart_js_for_static(content)).into_response()
6729            }
6730        }
6731        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6732            let filename = path.file_name().map_or_else(
6733                || "report.html".to_string(),
6734                |n| n.to_string_lossy().into_owned(),
6735            );
6736            let html = LocateFileTemplate {
6737                run_id: run_id.to_owned(),
6738                artifact_type: "html".to_string(),
6739                expected_filename: filename,
6740                server_mode,
6741                csp_nonce: csp_nonce.to_owned(),
6742                version: env!("CARGO_PKG_VERSION"),
6743            }
6744            .render()
6745            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6746            (StatusCode::NOT_FOUND, Html(html)).into_response()
6747        }
6748        Err(err) => {
6749            let filename = path.file_name().map_or_else(
6750                || "report.html".to_string(),
6751                |n| n.to_string_lossy().into_owned(),
6752            );
6753            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
6754            let html = ErrorTemplate {
6755                message: msg,
6756                last_report_url: Some("/view-reports".to_string()),
6757                last_report_label: Some("View Reports".to_string()),
6758                run_id: None,
6759                error_code: Some(404),
6760                csp_nonce: csp_nonce.to_owned(),
6761                version: env!("CARGO_PKG_VERSION"),
6762            }
6763            .render()
6764            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6765            (StatusCode::NOT_FOUND, Html(html)).into_response()
6766        }
6767    }
6768}
6769
6770/// Serve the PDF artifact for a run — inline or download.
6771fn serve_pdf_artifact(
6772    path: &Path,
6773    report_title: &str,
6774    run_id: &str,
6775    wants_download: bool,
6776    csp_nonce: &str,
6777) -> Response {
6778    match fs::read(path) {
6779        Ok(bytes) => {
6780            let filename = build_pdf_filename(report_title, run_id);
6781            let disposition = if wants_download {
6782                format!("attachment; filename=\"{filename}\"")
6783            } else {
6784                format!("inline; filename=\"{filename}\"")
6785            };
6786            (
6787                [
6788                    (header::CONTENT_TYPE, "application/pdf".to_string()),
6789                    (header::CONTENT_DISPOSITION, disposition),
6790                ],
6791                bytes,
6792            )
6793                .into_response()
6794        }
6795        Err(err) => {
6796            let filename = path.file_name().map_or_else(
6797                || "report.pdf".to_string(),
6798                |n| n.to_string_lossy().into_owned(),
6799            );
6800            let msg = format!(
6801                "PDF report '{filename}' could not be read.\n\n\
6802                 Error: {err}\n\n\
6803                 If you moved or renamed the output folder, the stored path is now stale. \
6804                 Use 'Open PDF folder' from the results page to browse the output directory."
6805            );
6806            let html = ErrorTemplate {
6807                message: msg,
6808                last_report_url: Some("/view-reports".to_string()),
6809                last_report_label: Some("View Reports".to_string()),
6810                run_id: Some(run_id.to_owned()),
6811                error_code: Some(404),
6812                csp_nonce: csp_nonce.to_owned(),
6813                version: env!("CARGO_PKG_VERSION"),
6814            }
6815            .render()
6816            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6817            (StatusCode::NOT_FOUND, Html(html)).into_response()
6818        }
6819    }
6820}
6821
6822/// Serve the JSON artifact for a run — view or download.
6823fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
6824    match fs::read(path) {
6825        Ok(bytes) => {
6826            if wants_download {
6827                (
6828                    [
6829                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
6830                        (
6831                            header::CONTENT_DISPOSITION,
6832                            "attachment; filename=result.json",
6833                        ),
6834                    ],
6835                    bytes,
6836                )
6837                    .into_response()
6838            } else {
6839                (
6840                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
6841                    bytes,
6842                )
6843                    .into_response()
6844            }
6845        }
6846        Err(err) => {
6847            let filename = path.file_name().map_or_else(
6848                || "result.json".to_string(),
6849                |n| n.to_string_lossy().into_owned(),
6850            );
6851            let msg = format!(
6852                "JSON result '{filename}' could not be read.\n\n\
6853                 Error: {err}\n\n\
6854                 If you moved or renamed the output folder, the stored path is now stale. \
6855                 Use 'Open JSON folder' from the results page to browse the output directory."
6856            );
6857            let html = ErrorTemplate {
6858                message: msg,
6859                last_report_url: Some("/view-reports".to_string()),
6860                last_report_label: Some("View Reports".to_string()),
6861                run_id: None,
6862                error_code: Some(404),
6863                csp_nonce: csp_nonce.to_owned(),
6864                version: env!("CARGO_PKG_VERSION"),
6865            }
6866            .render()
6867            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
6868            (StatusCode::NOT_FOUND, Html(html)).into_response()
6869        }
6870    }
6871}
6872
6873/// Recover a `RunArtifacts` from the persisted registry for a run ID.
6874fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
6875    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
6876    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
6877    let output_dir = entry
6878        .html_path
6879        .as_ref()
6880        .or(entry.json_path.as_ref())
6881        .or(entry.pdf_path.as_ref())
6882        .or(entry.csv_path.as_ref())
6883        .or(entry.xlsx_path.as_ref())
6884        .and_then(|p| {
6885            let parent = p.parent()?;
6886            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
6887            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
6888            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
6889                parent.parent().map(PathBuf::from)
6890            } else {
6891                Some(parent.to_path_buf())
6892            }
6893        })
6894        .unwrap_or_default();
6895    // Recover pdf_path: use the persisted one, or look for report.pdf
6896    // adjacent to html/json if only the old entries lack it.
6897    let pdf_path = entry.pdf_path.clone().or_else(|| {
6898        let candidate = output_dir.join("report.pdf");
6899        candidate.exists().then_some(candidate)
6900    });
6901    // csv_path / xlsx_path: persisted paths take precedence; fall back to
6902    // scanning the run directory for files matching the expected patterns so
6903    // that runs created before this feature still surface their artifacts.
6904    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
6905        // Check excel/ subfolder (new layout) then root (old layout).
6906        for dir in &[output_dir.join("excel"), output_dir.clone()] {
6907            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
6908                entries
6909                    .filter_map(std::result::Result::ok)
6910                    .find(|e| {
6911                        let n = e.file_name();
6912                        let n = n.to_string_lossy();
6913                        n.starts_with("report_") && n.ends_with(ext)
6914                    })
6915                    .map(|e| e.path())
6916            }) {
6917                return Some(p);
6918            }
6919        }
6920        None
6921    };
6922
6923    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
6924    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
6925    RunArtifacts {
6926        output_dir: output_dir.clone(),
6927        html_path: entry.html_path.clone(),
6928        pdf_path,
6929        json_path: entry.json_path.clone(),
6930        csv_path,
6931        xlsx_path,
6932        scan_config_path: find_scan_config_in_dir(&output_dir),
6933        report_title: entry.project_label.clone(),
6934        result_context: RunResultContext::default(),
6935    }
6936}
6937
6938#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
6939async fn resolve_artifact_set(
6940    state: &AppState,
6941    run_id: &str,
6942    csp_nonce: &str,
6943) -> Result<RunArtifacts, Response> {
6944    let cached = state.artifacts.lock().await.get(run_id).cloned();
6945    if let Some(a) = cached {
6946        return Ok(a);
6947    }
6948    let reg = state.registry.lock().await;
6949    if let Some(entry) = reg.find_by_run_id(run_id) {
6950        return Ok(recover_artifacts_from_registry(entry));
6951    }
6952    drop(reg);
6953    let short_id = &run_id[..run_id.len().min(8)];
6954    let hint = if matches!(
6955        run_id,
6956        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
6957    ) {
6958        format!(
6959            " The URL format appears to be reversed \u{2014} \
6960             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
6961             Use the View Reports page to navigate to your scan."
6962        )
6963    } else {
6964        " The report may have been deleted or the report directory moved. \
6965         Use View Reports to browse your scan history."
6966            .to_string()
6967    };
6968    let error_html = ErrorTemplate {
6969        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
6970        last_report_url: Some("/view-reports".to_string()),
6971        last_report_label: Some("View Reports".to_string()),
6972        run_id: None,
6973        error_code: Some(404),
6974        csp_nonce: csp_nonce.to_owned(),
6975        version: env!("CARGO_PKG_VERSION"),
6976    }
6977    .render()
6978    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
6979    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
6980}
6981
6982/// Return the path to a run's PDF, queuing background generation when it is missing.
6983///
6984/// Returns `Ok(path)` when the PDF is known (it may still be generating).
6985/// Returns `Err(response)` when there is no JSON source to regenerate from.
6986async fn resolve_or_queue_pdf(
6987    state: &AppState,
6988    pdf_path: Option<PathBuf>,
6989    json_path: Option<PathBuf>,
6990    output_dir: PathBuf,
6991    run_id: &str,
6992    report_title: &str,
6993    csp_nonce: &str,
6994) -> Result<PathBuf, Response> {
6995    if let Some(p) = pdf_path {
6996        return Ok(p);
6997    }
6998    let Some(json_src) = json_path.filter(|p| p.exists()) else {
6999        let msg = "PDF report was not generated for this run. \
7000                   Re-run the analysis with PDF output enabled."
7001            .to_string();
7002        let html = ErrorTemplate {
7003            message: msg,
7004            last_report_url: Some(format!("/runs/html/{run_id}")),
7005            last_report_label: Some("View HTML Report".to_string()),
7006            run_id: Some(run_id.to_string()),
7007            error_code: Some(404),
7008            csp_nonce: csp_nonce.to_string(),
7009            version: env!("CARGO_PKG_VERSION"),
7010        }
7011        .render()
7012        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7013        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7014    };
7015    let pdf_filename = build_pdf_filename(report_title, run_id);
7016    let pdf_dest = output_dir.join(&pdf_filename);
7017    if !pdf_dest.exists() {
7018        // Record the pending path so concurrent requests show the spinner.
7019        {
7020            let mut map = state.artifacts.lock().await;
7021            if let Some(entry) = map.get_mut(run_id) {
7022                entry.pdf_path = Some(pdf_dest.clone());
7023            }
7024        }
7025        {
7026            let mut reg = state.registry.lock().await;
7027            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7028                e.pdf_path = Some(pdf_dest.clone());
7029            }
7030            let _ = reg.save(&state.registry_path);
7031        }
7032        spawn_native_pdf_background(
7033            json_src,
7034            pdf_dest.clone(),
7035            run_id.to_string(),
7036            state.artifacts.clone(),
7037        );
7038    }
7039    Ok(pdf_dest)
7040}
7041
7042/// Self-refreshing "please wait" page shown while the background PDF task is still running.
7043fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7044    let html = format!(
7045                    "<!doctype html><html lang=\"en\"><head>\
7046                     <meta charset=utf-8>\
7047                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7048                     <meta http-equiv=\"refresh\" content=\"5\">\
7049                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
7050                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7051                     <style nonce=\"{csp_nonce}\">\
7052                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7053                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7054                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7055                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7056                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7057                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7058                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7059                     background:var(--bg);color:var(--text);}}\
7060                     .top-nav{{position:sticky;top:0;z-index:30;\
7061                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7062                     border-bottom:1px solid rgba(255,255,255,0.12);\
7063                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7064                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7065                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
7066                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7067                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7068                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7069                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7070                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7071                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7072                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7073                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7074                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7075                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7076                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7077                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7078                     justify-content:center;min-height:38px;border-radius:999px;\
7079                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7080                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7081                     .theme-toggle .icon-sun{{display:none;}}\
7082                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7083                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7084                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7085                     display:flex;align-items:center;justify-content:center;\
7086                     min-height:calc(100vh - 56px);}}\
7087                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7088                     .panel{{background:var(--surface);border:1px solid var(--line);\
7089                     border-radius:var(--radius);box-shadow:var(--shadow);\
7090                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7091                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
7092                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
7093                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7094                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7095                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7096                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7097                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7098                     min-height:42px;padding:0 20px;border-radius:14px;\
7099                     border:1px solid var(--line-strong);text-decoration:none;\
7100                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7101                     .back-link:hover{{background:var(--line);}}\
7102                     </style></head>\
7103                     <body>\
7104                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7105                       <a class=\"brand\" href=\"/\">\
7106                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7107                         <div class=\"brand-copy\">\
7108                           <div class=\"brand-title\">OxideSLOC</div>\
7109                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7110                         </div>\
7111                       </a>\
7112                       <div class=\"nav-right\">\
7113                         <a class=\"nav-pill\" href=\"/\">Home</a>\
7114                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7115                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7116                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7117                           <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>\
7118                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7119                           <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>\
7120                         </button>\
7121                       </div>\
7122                     </div></div>\
7123                     <div class=\"page\"><div class=\"panel\">\
7124                       <div class=\"spin-ring\"></div>\
7125                       <h1>Generating PDF\u{2026}</h1>\
7126                       <p>The PDF is being generated from the scan results.<br>\
7127                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7128                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7129                     </div></div>\
7130                     <script nonce=\"{csp_nonce}\">\
7131                     (function(){{\
7132                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7133                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7134                       var t=document.getElementById(\"theme-toggle\");\
7135                       if(t)t.addEventListener(\"click\",function(){{\
7136                         var d=b.classList.toggle(\"dark-theme\");\
7137                         localStorage.setItem(k,d?\"dark\":\"light\");\
7138                       }});\
7139                     }})();\
7140                     </script>\
7141                     </body></html>"
7142    );
7143    Html(html).into_response()
7144}
7145
7146/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7147fn render_error_artifact_html(
7148    message: String,
7149    last_report_url: Option<String>,
7150    last_report_label: Option<String>,
7151    run_id: Option<String>,
7152    error_code: Option<u16>,
7153    csp_nonce: &str,
7154) -> String {
7155    ErrorTemplate {
7156        message,
7157        last_report_url,
7158        last_report_label,
7159        run_id,
7160        error_code,
7161        csp_nonce: csp_nonce.to_owned(),
7162        version: env!("CARGO_PKG_VERSION"),
7163    }
7164    .render()
7165    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7166}
7167
7168/// Read a file and serve it as an attachment download.
7169fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7170    fs::read(path).map_or_else(
7171        |_| StatusCode::NOT_FOUND.into_response(),
7172        |bytes| {
7173            let filename = path.file_name().map_or_else(
7174                || fallback_filename.to_string(),
7175                |n| n.to_string_lossy().into_owned(),
7176            );
7177            (
7178                [
7179                    (header::CONTENT_TYPE, content_type.to_string()),
7180                    (
7181                        header::CONTENT_DISPOSITION,
7182                        format!("attachment; filename=\"{filename}\""),
7183                    ),
7184                ],
7185                bytes,
7186            )
7187                .into_response()
7188        },
7189    )
7190}
7191
7192fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7193    let Some(path) = csv_path else {
7194        let html = render_error_artifact_html(
7195            "CSV report was not generated for this run, or was not recorded in \
7196             the scan registry."
7197                .to_string(),
7198            Some(format!("/runs/html/{run_id}")),
7199            Some("View HTML Report".to_string()),
7200            Some(run_id.to_string()),
7201            Some(404),
7202            csp_nonce,
7203        );
7204        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7205    };
7206    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7207}
7208
7209fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7210    let Some(path) = xlsx_path else {
7211        let html = render_error_artifact_html(
7212            "Excel report was not generated for this run, or was not recorded in \
7213             the scan registry."
7214                .to_string(),
7215            Some(format!("/runs/html/{run_id}")),
7216            Some("View HTML Report".to_string()),
7217            Some(run_id.to_string()),
7218            Some(404),
7219            csp_nonce,
7220        );
7221        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7222    };
7223    serve_binary_download(
7224        &path,
7225        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7226        "report.xlsx",
7227    )
7228}
7229
7230fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7231    let path = artifact_set
7232        .scan_config_path
7233        .as_deref()
7234        .map(std::path::Path::to_path_buf)
7235        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7236        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7237    fs::read(&path).map_or_else(
7238        |_| StatusCode::NOT_FOUND.into_response(),
7239        |bytes| {
7240            (
7241                [
7242                    (
7243                        header::CONTENT_TYPE,
7244                        "application/json; charset=utf-8".to_string(),
7245                    ),
7246                    (
7247                        header::CONTENT_DISPOSITION,
7248                        "attachment; filename=\"scan-config.json\"".to_string(),
7249                    ),
7250                ],
7251                bytes,
7252            )
7253                .into_response()
7254        },
7255    )
7256}
7257
7258/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7259/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7260/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7261/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7262async fn serve_submodule_pdf_arm(
7263    artifact: &str,
7264    artifact_set: RunArtifacts,
7265    wants_download: bool,
7266    run_id: &str,
7267    csp_nonce: &str,
7268) -> Response {
7269    // "sub_benchmark_pdf" → base = "sub_benchmark"
7270    let base = artifact.trim_end_matches("_pdf");
7271    let sub_dir = artifact_set.output_dir.join("submodules");
7272    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7273
7274    if !pdf_path.exists() {
7275        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7276        let derived_safe = base.trim_start_matches("sub_");
7277        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7278            let parent_run = read_json(jp).ok()?;
7279            let sub = parent_run
7280                .submodule_summaries
7281                .iter()
7282                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7283                .clone();
7284            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7285            Some((parent_run, sub, parent_path))
7286        });
7287
7288        if let Some((parent_run, sub, parent_path)) = rebuilt {
7289            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7290            let pp = pdf_path.clone();
7291            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7292        }
7293    }
7294
7295    if !pdf_path.exists() {
7296        let html = render_error_artifact_html(
7297            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7298             enabled."
7299                .to_string(),
7300            Some("/view-reports".to_string()),
7301            Some("View Reports".to_string()),
7302            Some(run_id.to_string()),
7303            Some(404),
7304            csp_nonce,
7305        );
7306        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7307    }
7308
7309    serve_pdf_artifact(
7310        &pdf_path,
7311        &artifact_set.report_title,
7312        run_id,
7313        wants_download,
7314        csp_nonce,
7315    )
7316}
7317
7318fn serve_submodule_arm(
7319    artifact: &str,
7320    artifact_set: &RunArtifacts,
7321    wants_download: bool,
7322    csp_nonce: &str,
7323    run_id: &str,
7324    server_mode: bool,
7325) -> Response {
7326    if artifact.len() > 128
7327        || !artifact
7328            .chars()
7329            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7330    {
7331        return StatusCode::BAD_REQUEST.into_response();
7332    }
7333    let filename = format!("{artifact}.html");
7334    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7335    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7336    let path = if new_layout.exists() {
7337        new_layout
7338    } else {
7339        artifact_set.output_dir.join(&filename)
7340    };
7341    if !path.exists() {
7342        let html = render_error_artifact_html(
7343            format!(
7344                "Sub-report '{artifact}' was not found in the run directory.\n\
7345                 Re-run the analysis with 'Detect and separate git submodules' \
7346                 and HTML output enabled."
7347            ),
7348            Some("/view-reports".to_string()),
7349            Some("View Reports".to_string()),
7350            Some(run_id.to_string()),
7351            Some(404),
7352            csp_nonce,
7353        );
7354        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7355    }
7356    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7357}
7358
7359async fn serve_pdf_arm(
7360    state: &AppState,
7361    artifact_set: RunArtifacts,
7362    wants_download: bool,
7363    run_id: &str,
7364    csp_nonce: &str,
7365) -> Response {
7366    let report_title = artifact_set.report_title.clone();
7367    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7368    let stale_html_name = artifact_set
7369        .html_path
7370        .as_deref()
7371        .and_then(|p| p.file_name())
7372        .map(|n| n.to_string_lossy().into_owned());
7373    let path = match resolve_or_queue_pdf(
7374        state,
7375        artifact_set.pdf_path,
7376        artifact_set.json_path.clone(),
7377        artifact_set.output_dir.clone(),
7378        run_id,
7379        &report_title,
7380        csp_nonce,
7381    )
7382    .await
7383    {
7384        Ok(p) => p,
7385        Err(r) => return r,
7386    };
7387    if !path.exists() {
7388        // Distinguish a stale registry path (folder moved) from an in-progress
7389        // background generation. Only show the locate page when the PDF was
7390        // already recorded in the registry but the file is now missing.
7391        if had_pdf_in_registry {
7392            if let Some(expected_filename) = stale_html_name {
7393                let html = LocateFileTemplate {
7394                    run_id: run_id.to_string(),
7395                    artifact_type: "pdf".to_string(),
7396                    expected_filename,
7397                    server_mode: state.server_mode,
7398                    csp_nonce: csp_nonce.to_string(),
7399                    version: env!("CARGO_PKG_VERSION"),
7400                }
7401                .render()
7402                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7403                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7404            }
7405        }
7406        return pdf_generating_response(run_id, csp_nonce);
7407    }
7408    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7409}
7410
7411async fn artifact_handler(
7412    State(state): State<AppState>,
7413    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7414    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7415    Query(query): Query<ArtifactQuery>,
7416) -> Response {
7417    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7418        Ok(a) => a,
7419        Err(r) => return r,
7420    };
7421
7422    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7423
7424    match artifact.as_str() {
7425        "html" => {
7426            let Some(path) = artifact_set.html_path else {
7427                return StatusCode::NOT_FOUND.into_response();
7428            };
7429            serve_html_artifact(
7430                &path,
7431                wants_download,
7432                &csp_nonce,
7433                &run_id,
7434                state.server_mode,
7435            )
7436        }
7437        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7438        "json" => {
7439            let Some(path) = artifact_set.json_path else {
7440                let html = render_error_artifact_html(
7441                    "JSON result was not generated for this run, or was not recorded in \
7442                     the scan registry. Re-run the analysis with JSON output enabled."
7443                        .to_string(),
7444                    Some("/view-reports".to_string()),
7445                    Some("View Reports".to_string()),
7446                    Some(run_id.clone()),
7447                    Some(404),
7448                    &csp_nonce,
7449                );
7450                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7451            };
7452            serve_json_artifact(&path, wants_download, &csp_nonce)
7453        }
7454        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7455        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7456        "scan-config" => serve_scan_config_arm(&artifact_set),
7457        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7458            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7459                .await
7460        }
7461        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7462            &artifact,
7463            &artifact_set,
7464            wants_download,
7465            &csp_nonce,
7466            &run_id,
7467            state.server_mode,
7468        ),
7469        _ => StatusCode::NOT_FOUND.into_response(),
7470    }
7471}
7472
7473// ── History ───────────────────────────────────────────────────────────────────
7474
7475struct SubmoduleLinkRow {
7476    name: String,
7477    url: String,
7478}
7479
7480struct HistoryEntryRow {
7481    run_id: String,
7482    run_id_short: String,
7483    timestamp: String,
7484    timestamp_utc_ms: i64,
7485    project_label: String,
7486    project_path: String,
7487    files_analyzed: u64,
7488    files_skipped: u64,
7489    code_lines: u64,
7490    comment_lines: u64,
7491    blank_lines: u64,
7492    total_physical_lines: u64,
7493    functions: u64,
7494    classes: u64,
7495    variables: u64,
7496    imports: u64,
7497    test_count: u64,
7498    git_branch: String,
7499    git_commit: String,
7500    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7501    git_commit_long: String,
7502    has_html: bool,
7503    has_json: bool,
7504    has_pdf: bool,
7505    submodule_links: Vec<SubmoduleLinkRow>,
7506    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7507    submodule_names_csv: String,
7508}
7509
7510/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7511fn nth_weekday_of_month(
7512    year: i32,
7513    month: u32,
7514    weekday: chrono::Weekday,
7515    n: u32,
7516) -> chrono::NaiveDate {
7517    use chrono::Datelike;
7518    let mut count = 0u32;
7519    let mut day = 1u32;
7520    loop {
7521        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7522        if d.weekday() == weekday {
7523            count += 1;
7524            if count == n {
7525                return d;
7526            }
7527        }
7528        day += 1;
7529    }
7530}
7531
7532/// Returns true if `dt` falls within US Pacific Daylight Time.
7533/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7534/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7535fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7536    use chrono::{Datelike, TimeZone};
7537    let year = dt.year();
7538    let dst_start = chrono::Utc.from_utc_datetime(
7539        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7540            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7541    );
7542    let dst_end = chrono::Utc.from_utc_datetime(
7543        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7544            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7545    );
7546    dt >= dst_start && dt < dst_end
7547}
7548
7549fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7550    if is_pacific_dst(dt) {
7551        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7552            .format("%Y-%m-%d %H:%M PDT")
7553            .to_string()
7554    } else {
7555        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7556            .format("%Y-%m-%d %H:%M PST")
7557            .to_string()
7558    }
7559}
7560
7561/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7562fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7563    let (offset, tz) = if is_pacific_dst(dt) {
7564        (
7565            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7566            "PDT",
7567        )
7568    } else {
7569        (
7570            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7571            "PST",
7572        )
7573    };
7574    format!(
7575        "{} {tz}",
7576        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7577    )
7578}
7579
7580fn fmt_git_date(iso: &str) -> Option<String> {
7581    chrono::DateTime::parse_from_rfc3339(iso)
7582        .ok()
7583        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7584}
7585
7586/// Recover the full-length commit SHA for a registry entry whose stored record
7587/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7588///
7589/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7590/// is serialized after the per-file records, near the end of the file. We read a
7591/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7592/// known short SHA — this disambiguates the super-repo commit from any submodule
7593/// commits that also appear. Returns `None` if the file is unreadable or no match.
7594fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7595    use std::io::{Read, Seek, SeekFrom};
7596    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7597    if short.is_empty() {
7598        return None;
7599    }
7600    let len = std::fs::metadata(path).ok()?.len();
7601    let start = len.saturating_sub(TAIL);
7602    let mut file = std::fs::File::open(path).ok()?;
7603    file.seek(SeekFrom::Start(start)).ok()?;
7604    let mut buf = Vec::new();
7605    file.read_to_end(&mut buf).ok()?;
7606    let text = String::from_utf8_lossy(&buf);
7607    let short_lower = short.to_ascii_lowercase();
7608    let key = "\"git_commit_long\"";
7609    let mut found: Option<String> = None;
7610    let mut cursor = 0usize;
7611    while let Some(idx) = text[cursor..].find(key) {
7612        let after_key = cursor + idx + key.len();
7613        cursor = after_key;
7614        let rest = &text[after_key..];
7615        let Some(colon) = rest.find(':') else { break };
7616        let value_region = rest[colon + 1..].trim_start();
7617        // Skip `null` (or any non-string) values without consuming the next field.
7618        if let Some(open) = value_region.strip_prefix('"') {
7619            if let Some(close) = open.find('"') {
7620                let val = &open[..close];
7621                if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7622                    found = Some(val.to_string());
7623                }
7624            }
7625        }
7626    }
7627    found
7628}
7629
7630fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7631    reg.entries
7632        .iter()
7633        .map(|e| {
7634            let submodule_links = {
7635                let mut links: Vec<SubmoduleLinkRow> = vec![];
7636                let sub_dir = e
7637                    .html_path
7638                    .as_ref()
7639                    .and_then(|p| p.parent())
7640                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7641                if let Some(dir) = sub_dir {
7642                    if let Ok(rd) = std::fs::read_dir(dir) {
7643                        for entry_res in rd.flatten() {
7644                            let fname = entry_res.file_name();
7645                            let fname_str = fname.to_string_lossy();
7646                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7647                                let stem = &fname_str[..fname_str.len() - 5];
7648                                let display = stem[4..].replace('-', " ");
7649                                links.push(SubmoduleLinkRow {
7650                                    name: display,
7651                                    url: format!("/runs/{stem}/{}", e.run_id),
7652                                });
7653                            }
7654                        }
7655                    }
7656                }
7657                links.sort_by(|a, b| a.name.cmp(&b.name));
7658                links
7659            };
7660            let submodule_names_csv = submodule_links
7661                .iter()
7662                .map(|l| l.name.as_str())
7663                .collect::<Vec<_>>()
7664                .join(",");
7665            HistoryEntryRow {
7666                run_id: e.run_id.clone(),
7667                run_id_short: e
7668                    .run_id
7669                    .split('-')
7670                    .next_back()
7671                    .unwrap_or(&e.run_id)
7672                    .chars()
7673                    .take(7)
7674                    .collect(),
7675                timestamp: fmt_la_time(e.timestamp_utc),
7676                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7677                project_label: e.project_label.clone(),
7678                project_path: e
7679                    .input_roots
7680                    .first()
7681                    .map(|s| sanitize_path_str(s))
7682                    .unwrap_or_default(),
7683                files_analyzed: e.summary.files_analyzed,
7684                files_skipped: e.summary.files_skipped,
7685                code_lines: e.summary.code_lines,
7686                comment_lines: e.summary.comment_lines,
7687                blank_lines: e.summary.blank_lines,
7688                total_physical_lines: e.summary.total_physical_lines,
7689                functions: e.summary.functions,
7690                classes: e.summary.classes,
7691                variables: e.summary.variables,
7692                imports: e.summary.imports,
7693                test_count: e.summary.test_count,
7694                git_branch: e.git_branch.clone().unwrap_or_default(),
7695                git_commit: e.git_commit.clone().unwrap_or_default(),
7696                git_commit_long: {
7697                    let short = e.git_commit.clone().unwrap_or_default();
7698                    e.git_commit_long
7699                        .clone()
7700                        .filter(|s| !s.is_empty())
7701                        .or_else(|| {
7702                            e.json_path
7703                                .as_ref()
7704                                .and_then(|p| extract_long_commit_from_json(p, &short))
7705                        })
7706                        .unwrap_or(short)
7707                },
7708                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7709                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7710                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7711                submodule_links,
7712                submodule_names_csv,
7713            }
7714        })
7715        .collect()
7716}
7717
7718#[derive(Deserialize, Default)]
7719struct HistoryQuery {
7720    linked: Option<String>,
7721    error: Option<String>,
7722}
7723
7724async fn history_handler(
7725    State(state): State<AppState>,
7726    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7727    Query(query): Query<HistoryQuery>,
7728) -> impl IntoResponse {
7729    // Auto-scan all watched directories before rendering so the list stays fresh.
7730    auto_scan_watched_dirs(&state).await;
7731    let watched_dirs: Vec<String> = {
7732        let wd = state.watched_dirs.lock().await;
7733        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7734    };
7735    let mut entries = {
7736        let reg = state.registry.lock().await;
7737        make_history_rows(&reg)
7738    };
7739    entries.retain(|e| e.has_html);
7740    let total_scans = entries.len();
7741    let linked_count = query
7742        .linked
7743        .as_deref()
7744        .and_then(|s| s.parse::<usize>().ok())
7745        .unwrap_or(0);
7746    let browse_error = query.error.filter(|s| !s.is_empty());
7747    let template = HistoryTemplate {
7748        version: env!("CARGO_PKG_VERSION"),
7749        entries,
7750        total_scans,
7751        linked_count,
7752        browse_error,
7753        watched_dirs,
7754        csp_nonce,
7755        server_mode: state.server_mode,
7756    };
7757    Html(
7758        template
7759            .render()
7760            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7761    )
7762    .into_response()
7763}
7764
7765async fn compare_select_handler(
7766    State(state): State<AppState>,
7767    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7768) -> impl IntoResponse {
7769    auto_scan_watched_dirs(&state).await;
7770    let watched_dirs: Vec<String> = {
7771        let wd = state.watched_dirs.lock().await;
7772        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7773    };
7774    let mut entries = {
7775        let reg = state.registry.lock().await;
7776        make_history_rows(&reg)
7777    };
7778    entries.retain(|e| e.has_json);
7779    let total_scans = entries.len();
7780    let template = CompareSelectTemplate {
7781        version: env!("CARGO_PKG_VERSION"),
7782        entries,
7783        total_scans,
7784        watched_dirs,
7785        csp_nonce,
7786        server_mode: state.server_mode,
7787    };
7788    Html(
7789        template
7790            .render()
7791            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
7792    )
7793    .into_response()
7794}
7795
7796// ── Compare ───────────────────────────────────────────────────────────────────
7797
7798#[derive(Deserialize, Default)]
7799struct CompareQuery {
7800    a: Option<String>,
7801    b: Option<String>,
7802    /// Optional submodule name to scope the comparison to one submodule.
7803    sub: Option<String>,
7804    /// "super" to exclude all submodule files and show only the super-repo.
7805    scope: Option<String>,
7806}
7807
7808struct CompareFileDeltaRow {
7809    relative_path: String,
7810    language: String,
7811    status: String,
7812    baseline_code: i64,
7813    current_code: i64,
7814    baseline_code_display: String,
7815    current_code_display: String,
7816    code_delta_str: String,
7817    code_delta_class: String,
7818    comment_delta_str: String,
7819    comment_delta_class: String,
7820    total_delta_str: String,
7821    total_delta_class: String,
7822}
7823
7824/// Recompute `summary_totals` from the current `per_file_records` slice.
7825/// Used when `per_file_records` has been narrowed to a submodule subset.
7826fn recompute_summary_from_records(run: &mut AnalysisRun) {
7827    let mut totals = SummaryTotals::default();
7828    for r in &run.per_file_records {
7829        if r.language.is_some() {
7830            totals.files_analyzed += 1;
7831        }
7832        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
7833        totals.code_lines += r.effective_counts.code_lines;
7834        totals.comment_lines += r.effective_counts.comment_lines;
7835        totals.blank_lines += r.effective_counts.blank_lines;
7836        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
7837        totals.functions += r.raw_line_categories.functions;
7838        totals.classes += r.raw_line_categories.classes;
7839        totals.variables += r.raw_line_categories.variables;
7840        totals.imports += r.raw_line_categories.imports;
7841        totals.test_count += r.raw_line_categories.test_count;
7842        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
7843        totals.test_suite_count += r.raw_line_categories.test_suite_count;
7844        if let Some(cov) = &r.coverage {
7845            totals.coverage_lines_found += u64::from(cov.lines_found);
7846            totals.coverage_lines_hit += u64::from(cov.lines_hit);
7847            totals.coverage_functions_found += u64::from(cov.functions_found);
7848            totals.coverage_functions_hit += u64::from(cov.functions_hit);
7849            totals.coverage_branches_found += u64::from(cov.branches_found);
7850            totals.coverage_branches_hit += u64::from(cov.branches_hit);
7851        }
7852    }
7853    totals.files_considered = totals.files_analyzed;
7854    run.summary_totals = totals;
7855}
7856
7857fn fmt_delta(n: i64) -> String {
7858    if n > 0 {
7859        format!("+{n}")
7860    } else {
7861        format!("{n}")
7862    }
7863}
7864
7865fn delta_class(n: i64) -> &'static str {
7866    use std::cmp::Ordering;
7867    match n.cmp(&0) {
7868        Ordering::Greater => "pos",
7869        Ordering::Less => "neg",
7870        Ordering::Equal => "zero",
7871    }
7872}
7873
7874// ratio/percentage display, precision loss acceptable
7875#[allow(clippy::cast_precision_loss)]
7876fn fmt_pct(delta: i64, baseline: u64) -> String {
7877    if baseline == 0 {
7878        return "—".to_string();
7879    }
7880    #[allow(clippy::cast_precision_loss)]
7881    let pct = (delta as f64 / baseline as f64) * 100.0;
7882    if pct > 0.049 {
7883        format!("+{pct:.1}%")
7884    } else if pct < -0.049 {
7885        format!("{pct:.1}%")
7886    } else {
7887        "±0%".to_string()
7888    }
7889}
7890
7891/// Returns (`display_string`, `css_class`) for a numeric change column cell.
7892fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
7893    prev.map_or_else(
7894        || ("—".to_string(), "na"),
7895        |p| {
7896            #[allow(clippy::cast_possible_wrap)]
7897            let d = curr as i64 - p as i64;
7898            (fmt_delta(d), delta_class(d))
7899        },
7900    )
7901}
7902
7903#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
7904fn load_scan_for_compare(
7905    json_path: &std::path::Path,
7906    scan_label: &str,
7907    run_id: &str,
7908    server_mode: bool,
7909    compare_url: &str,
7910    csp_nonce: &str,
7911) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
7912    match read_json(json_path) {
7913        Ok(r) => Ok(r),
7914        Err(e) => {
7915            if server_mode {
7916                let html = ErrorTemplate {
7917                    message: format!(
7918                        "Could not load {scan_label} scan data. The scan output folder may have \
7919                         been moved, renamed, or deleted. Re-running the analysis will create \
7920                         fresh comparison data."
7921                    ),
7922                    last_report_url: Some("/compare-scans".to_string()),
7923                    last_report_label: Some("Compare Scans".to_string()),
7924                    run_id: Some(run_id.to_owned()),
7925                    error_code: Some(404),
7926                    csp_nonce: csp_nonce.to_owned(),
7927                    version: env!("CARGO_PKG_VERSION"),
7928                }
7929                .render()
7930                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
7931                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7932            }
7933            let msg = format!(
7934                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
7935                json_path.display()
7936            );
7937            let folder_hint = output_folder_hint(json_path);
7938            Err(missing_scan_relocate_response(
7939                &msg,
7940                run_id,
7941                &folder_hint,
7942                compare_url,
7943                false,
7944                csp_nonce,
7945            ))
7946        }
7947    }
7948}
7949
7950struct ChurnStats {
7951    new_scope: bool,
7952    scope_flag: bool,
7953    churn_rate_str: String,
7954    churn_rate_class: String,
7955}
7956
7957fn compute_churn_stats(
7958    baseline_code: u64,
7959    current_code: u64,
7960    lines_added: i64,
7961    lines_removed: i64,
7962) -> ChurnStats {
7963    let new_scope = baseline_code == 0 && current_code > 0;
7964    #[allow(clippy::cast_precision_loss)]
7965    let churn_pct = if baseline_code > 0 {
7966        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
7967    } else {
7968        0.0
7969    };
7970    #[allow(clippy::cast_precision_loss)]
7971    let scope_flag =
7972        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
7973    let churn_rate_str = if new_scope {
7974        "New".to_string()
7975    } else if baseline_code > 0 {
7976        format!("{churn_pct:.1}%")
7977    } else {
7978        "—".to_string()
7979    };
7980    let churn_rate_class = if new_scope || churn_pct > 20.0 {
7981        "high".to_string()
7982    } else if churn_pct > 5.0 {
7983        "med".to_string()
7984    } else {
7985        "low".to_string()
7986    };
7987    ChurnStats {
7988        new_scope,
7989        scope_flag,
7990        churn_rate_str,
7991        churn_rate_class,
7992    }
7993}
7994
7995/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
7996/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
7997/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
7998fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
7999    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8000    if !has_data {
8001        return String::new();
8002    }
8003    let base_str = s
8004        .baseline_coverage_line_pct
8005        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8006    let curr_str = s
8007        .current_coverage_line_pct
8008        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8009    let (delta_str, cls) = match s.coverage_line_pct_delta {
8010        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8011        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8012        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8013        None => ("\u{2014}".into(), "zero"),
8014    };
8015    format!(
8016        r#"<div class="delta-card">
8017          <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>
8018          <div class="delta-card-label">Line coverage</div>
8019          <div class="delta-card-from">Before: {base_str}</div>
8020          <div class="delta-card-to">{curr_str}</div>
8021          <span class="delta-card-change {cls}">{delta_str}</span>
8022        </div>"#
8023    )
8024}
8025
8026/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
8027#[allow(clippy::ref_option)]
8028fn narrow_run_pair_by_scope(
8029    mut baseline: AnalysisRun,
8030    mut current: AnalysisRun,
8031    active_sub: &Option<String>,
8032    super_scope: bool,
8033) -> (AnalysisRun, AnalysisRun) {
8034    if let Some(ref sub_name) = active_sub {
8035        baseline
8036            .per_file_records
8037            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8038        current
8039            .per_file_records
8040            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8041        recompute_summary_from_records(&mut baseline);
8042        recompute_summary_from_records(&mut current);
8043    } else if super_scope {
8044        baseline.per_file_records.retain(|f| f.submodule.is_none());
8045        current.per_file_records.retain(|f| f.submodule.is_none());
8046        recompute_summary_from_records(&mut baseline);
8047        recompute_summary_from_records(&mut current);
8048    }
8049    (baseline, current)
8050}
8051
8052/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
8053#[allow(clippy::ref_option)]
8054fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8055    if let Some(ref sub_name) = active_sub {
8056        for run in runs.iter_mut() {
8057            run.per_file_records
8058                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8059            recompute_summary_from_records(run);
8060        }
8061    } else if super_scope {
8062        for run in runs.iter_mut() {
8063            run.per_file_records.retain(|f| f.submodule.is_none());
8064            recompute_summary_from_records(run);
8065        }
8066    }
8067}
8068
8069#[allow(clippy::too_many_lines)]
8070async fn compare_handler(
8071    State(state): State<AppState>,
8072    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8073    Query(query): Query<CompareQuery>,
8074) -> impl IntoResponse {
8075    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
8076    // redirect to the history page where the user can select two runs.
8077    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8078        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8079        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8080    };
8081
8082    let (maybe_a, maybe_b) = {
8083        let reg = state.registry.lock().await;
8084        (
8085            reg.find_by_run_id(&run_id_a).cloned(),
8086            reg.find_by_run_id(&run_id_b).cloned(),
8087        )
8088    };
8089
8090    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8091        let html = ErrorTemplate {
8092            message: "One or both run IDs were not found in scan history. \
8093                      The runs may have been deleted or the registry may have been reset."
8094                .to_string(),
8095            last_report_url: Some("/compare-scans".to_string()),
8096            last_report_label: Some("Compare Scans".to_string()),
8097            run_id: None,
8098            error_code: None,
8099            csp_nonce: csp_nonce.clone(),
8100            version: env!("CARGO_PKG_VERSION"),
8101        }
8102        .render()
8103        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8104        return Html(html).into_response();
8105    };
8106
8107    // Ensure older scan is always the baseline.
8108    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8109        (entry_a, entry_b)
8110    } else {
8111        (entry_b, entry_a)
8112    };
8113
8114    // If query params were in the wrong order, redirect to canonical URL so the
8115    // browser always shows the same URL for the same two scans regardless of how
8116    // the user arrived here (Full diff button vs. Compare Scans selection).
8117    if baseline_entry.run_id != run_id_a {
8118        let canonical = format!(
8119            "/compare?a={}&b={}",
8120            baseline_entry.run_id, current_entry.run_id
8121        );
8122        return axum::response::Redirect::to(&canonical).into_response();
8123    }
8124
8125    let (Some(base_json), Some(curr_json)) = (
8126        baseline_entry.json_path.as_ref(),
8127        current_entry.json_path.as_ref(),
8128    ) else {
8129        let html = ErrorTemplate {
8130            message: "Full comparison requires JSON scan data, which was not saved for one or \
8131                      both of these runs. JSON is now always saved for new scans — re-run the \
8132                      affected projects to enable comparisons."
8133                .to_string(),
8134            last_report_url: Some("/compare-scans".to_string()),
8135            last_report_label: Some("Compare Scans".to_string()),
8136            run_id: None,
8137            error_code: None,
8138            csp_nonce: csp_nonce.clone(),
8139            version: env!("CARGO_PKG_VERSION"),
8140        }
8141        .render()
8142        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8143        return Html(html).into_response();
8144    };
8145
8146    let compare_url = format!(
8147        "/compare?a={}&b={}",
8148        baseline_entry.run_id, current_entry.run_id
8149    );
8150
8151    let baseline_run = match load_scan_for_compare(
8152        base_json,
8153        "baseline",
8154        &baseline_entry.run_id,
8155        state.server_mode,
8156        &compare_url,
8157        &csp_nonce,
8158    ) {
8159        Ok(r) => r,
8160        Err(resp) => return resp,
8161    };
8162    let current_run = match load_scan_for_compare(
8163        curr_json,
8164        "current",
8165        &current_entry.run_id,
8166        state.server_mode,
8167        &compare_url,
8168        &csp_nonce,
8169    ) {
8170        Ok(r) => r,
8171        Err(resp) => return resp,
8172    };
8173
8174    let active_submodule = query.sub.clone();
8175    let super_scope_active = query.scope.as_deref() == Some("super");
8176
8177    let submodule_options = baseline_run
8178        .submodule_summaries
8179        .iter()
8180        .chain(current_run.submodule_summaries.iter())
8181        .map(|s| s.name.clone())
8182        .collect::<std::collections::BTreeSet<_>>()
8183        .into_iter()
8184        .collect::<Vec<_>>();
8185    let has_any_submodule_data = !submodule_options.is_empty();
8186
8187    // Narrow per_file_records when a scope is active, then recompute totals.
8188    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8189        baseline_run,
8190        current_run,
8191        &active_submodule,
8192        super_scope_active,
8193    );
8194
8195    let comparison = compute_delta(&effective_baseline, &effective_current);
8196
8197    let file_rows: Vec<CompareFileDeltaRow> = comparison
8198        .file_deltas
8199        .iter()
8200        .map(|d| CompareFileDeltaRow {
8201            relative_path: d.relative_path.clone(),
8202            language: d.language.clone().unwrap_or_else(|| "—".into()),
8203            status: match d.status {
8204                FileChangeStatus::Added => "added".into(),
8205                FileChangeStatus::Removed => "removed".into(),
8206                FileChangeStatus::Modified => "modified".into(),
8207                FileChangeStatus::Unchanged => "unchanged".into(),
8208            },
8209            baseline_code: d.baseline_code,
8210            current_code: d.current_code,
8211            baseline_code_display: if d.status == FileChangeStatus::Added {
8212                "—".into()
8213            } else {
8214                d.baseline_code.to_string()
8215            },
8216            current_code_display: if d.status == FileChangeStatus::Removed {
8217                "—".into()
8218            } else {
8219                d.current_code.to_string()
8220            },
8221            code_delta_str: fmt_delta(d.code_delta),
8222            code_delta_class: delta_class(d.code_delta).into(),
8223            comment_delta_str: fmt_delta(d.comment_delta),
8224            comment_delta_class: delta_class(d.comment_delta).into(),
8225            total_delta_str: fmt_delta(d.total_delta),
8226            total_delta_class: delta_class(d.total_delta).into(),
8227        })
8228        .collect();
8229
8230    let project_path = baseline_entry
8231        .input_roots
8232        .first()
8233        .map(|s| sanitize_path_str(s))
8234        .unwrap_or_default();
8235    let lines_added = sum_added_code_lines(&comparison);
8236    let lines_removed = sum_removed_code_lines(&comparison);
8237    let churn = compute_churn_stats(
8238        comparison.summary.baseline_code,
8239        comparison.summary.current_code,
8240        lines_added,
8241        lines_removed,
8242    );
8243    let s = &comparison.summary;
8244    let template = CompareTemplate {
8245        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8246        version: env!("CARGO_PKG_VERSION"),
8247        project_label: baseline_entry.project_label.clone(),
8248        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8249        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8250        baseline_run_id: baseline_entry.run_id.clone(),
8251        current_run_id: current_entry.run_id.clone(),
8252        baseline_run_id_short: baseline_entry
8253            .run_id
8254            .split('-')
8255            .next_back()
8256            .unwrap_or(&baseline_entry.run_id)
8257            .chars()
8258            .take(7)
8259            .collect(),
8260        current_run_id_short: current_entry
8261            .run_id
8262            .split('-')
8263            .next_back()
8264            .unwrap_or(&current_entry.run_id)
8265            .chars()
8266            .take(7)
8267            .collect(),
8268        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8269        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8270        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8271        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8272        project_path: project_path.clone(),
8273        baseline_code: s.baseline_code,
8274        current_code: s.current_code,
8275        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8276        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8277        baseline_files: s.baseline_files,
8278        current_files: s.current_files,
8279        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8280        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8281        baseline_comments: s.baseline_comments,
8282        current_comments: s.current_comments,
8283        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8284        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8285        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8286        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8287        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8288        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8289        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8290        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8291        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8292        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8293        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8294        code_lines_added: lines_added,
8295        code_lines_removed: lines_removed,
8296        code_lines_modified: sum_modified_code_lines(&comparison),
8297        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8298        new_scope: churn.new_scope,
8299        churn_rate_str: churn.churn_rate_str,
8300        churn_rate_class: churn.churn_rate_class,
8301        scope_flag: churn.scope_flag,
8302        files_added: comparison.files_added,
8303        files_removed: comparison.files_removed,
8304        files_modified: comparison.files_modified,
8305        files_unchanged: comparison.files_unchanged,
8306        file_rows,
8307        baseline_git_author: baseline_entry.git_author.clone(),
8308        current_git_author: current_entry.git_author.clone(),
8309        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8310        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8311        baseline_git_tags: baseline_entry.git_tags.clone(),
8312        current_git_tags: current_entry.git_tags.clone(),
8313        baseline_git_commit_date: baseline_entry
8314            .git_commit_date
8315            .as_deref()
8316            .and_then(fmt_git_date),
8317        current_git_commit_date: current_entry
8318            .git_commit_date
8319            .as_deref()
8320            .and_then(fmt_git_date),
8321        project_name: project_path
8322            .rsplit(['/', '\\'])
8323            .find(|s| !s.is_empty())
8324            .unwrap_or(&project_path)
8325            .to_string(),
8326        submodule_options,
8327        has_any_submodule_data,
8328        active_submodule,
8329        super_scope_active,
8330        toast_assets: sloc_toast_assets(&csp_nonce),
8331        csp_nonce,
8332        coverage_delta_card: build_coverage_delta_card(s),
8333        baseline_test_count: effective_baseline.summary_totals.test_count,
8334        current_test_count: effective_current.summary_totals.test_count,
8335        baseline_coverage_pct: s.baseline_coverage_line_pct,
8336        current_coverage_pct: s.current_coverage_line_pct,
8337    };
8338
8339    Html(
8340        template
8341            .render()
8342            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8343    )
8344    .into_response()
8345}
8346
8347// ── Badge endpoint ────────────────────────────────────────────────────────────
8348// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8349// pages, Jira descriptions, etc.
8350//
8351// GET /badge/<metric>?label=<override>&color=<hex>
8352// Metrics: code-lines  files  comment-lines  blank-lines
8353
8354fn format_number(n: u64) -> String {
8355    let s = n.to_string();
8356    let mut out = String::with_capacity(s.len() + s.len() / 3);
8357    let len = s.len();
8358    for (i, c) in s.chars().enumerate() {
8359        if i > 0 && (len - i).is_multiple_of(3) {
8360            out.push(',');
8361        }
8362        out.push(c);
8363    }
8364    out
8365}
8366
8367const fn badge_char_width(c: char) -> f64 {
8368    match c {
8369        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8370        'm' | 'w' => 9.0,
8371        ' ' => 4.0,
8372        _ => 6.5,
8373    }
8374}
8375
8376#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8377fn badge_text_px(text: &str) -> u32 {
8378    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8379}
8380
8381fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8382    let lw = badge_text_px(label) + 20;
8383    let rw = badge_text_px(value) + 20;
8384    let total = lw + rw;
8385    let lx = lw / 2;
8386    let rx = lw + rw / 2;
8387    let le = escape_html(label);
8388    let ve = escape_html(value);
8389    let ce = escape_html(color);
8390    format!(
8391        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8392  <rect width="{total}" height="20" fill="#555"/>
8393  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8394  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8395    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8396    <text x="{lx}" y="13">{le}</text>
8397    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8398    <text x="{rx}" y="13">{ve}</text>
8399  </g>
8400</svg>"##
8401    )
8402}
8403
8404#[derive(Deserialize)]
8405struct BadgeQuery {
8406    label: Option<String>,
8407    color: Option<String>,
8408}
8409
8410async fn badge_handler(
8411    State(state): State<AppState>,
8412    AxumPath(metric): AxumPath<String>,
8413    Query(query): Query<BadgeQuery>,
8414) -> Response {
8415    let entry = {
8416        let reg = state.registry.lock().await;
8417        reg.entries.first().cloned()
8418    };
8419
8420    let Some(entry) = entry else {
8421        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8422        return (
8423            [
8424                (header::CONTENT_TYPE, "image/svg+xml"),
8425                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8426            ],
8427            svg,
8428        )
8429            .into_response();
8430    };
8431
8432    let (default_label, value, default_color) = match metric.as_str() {
8433        "code-lines" => (
8434            "code lines",
8435            format_number(entry.summary.code_lines),
8436            "#4a78ee",
8437        ),
8438        "files" => (
8439            "files analyzed",
8440            format_number(entry.summary.files_analyzed),
8441            "#4a9862",
8442        ),
8443        "comment-lines" => (
8444            "comment lines",
8445            format_number(entry.summary.comment_lines),
8446            "#b35428",
8447        ),
8448        "blank-lines" => (
8449            "blank lines",
8450            format_number(entry.summary.blank_lines),
8451            "#7a5db0",
8452        ),
8453        _ => return StatusCode::NOT_FOUND.into_response(),
8454    };
8455
8456    let label = query.label.as_deref().unwrap_or(default_label);
8457    let color = query.color.as_deref().unwrap_or(default_color);
8458    let svg = render_badge_svg(label, &value, color);
8459
8460    (
8461        [
8462            (header::CONTENT_TYPE, "image/svg+xml"),
8463            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8464        ],
8465        svg,
8466    )
8467        .into_response()
8468}
8469
8470// ── Metrics API ───────────────────────────────────────────────────────────────
8471// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8472// Confluence automation, Jira webhooks, etc.
8473//
8474// GET /api/metrics/latest
8475// GET /api/metrics/<run_id>
8476
8477#[derive(Serialize)]
8478struct ApiCoverageBlock {
8479    lines_found: u64,
8480    lines_hit: u64,
8481    line_pct: f64,
8482    functions_found: u64,
8483    functions_hit: u64,
8484    function_pct: f64,
8485    branches_found: u64,
8486    branches_hit: u64,
8487    branch_pct: f64,
8488}
8489
8490#[derive(Serialize)]
8491struct ApiMetricsResponse {
8492    run_id: String,
8493    timestamp: String,
8494    project: String,
8495    summary: ApiSummaryPayload,
8496    languages: Vec<ApiLanguageRow>,
8497    #[serde(skip_serializing_if = "Option::is_none")]
8498    coverage: Option<ApiCoverageBlock>,
8499}
8500
8501#[derive(Serialize)]
8502struct ApiSummaryPayload {
8503    files_analyzed: u64,
8504    files_skipped: u64,
8505    code_lines: u64,
8506    comment_lines: u64,
8507    blank_lines: u64,
8508    total_physical_lines: u64,
8509    functions: u64,
8510    classes: u64,
8511    variables: u64,
8512    imports: u64,
8513}
8514
8515#[derive(Serialize)]
8516struct ApiLanguageRow {
8517    name: String,
8518    files: u64,
8519    code_lines: u64,
8520    comment_lines: u64,
8521    blank_lines: u64,
8522    functions: u64,
8523    classes: u64,
8524    variables: u64,
8525    imports: u64,
8526}
8527
8528async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8529    let entry = {
8530        let reg = state.registry.lock().await;
8531        reg.entries.first().cloned()
8532    };
8533    entry.map_or_else(
8534        || error::not_found("no scans recorded yet"),
8535        |e| build_metrics_response(&e),
8536    )
8537}
8538
8539async fn api_metrics_run_handler(
8540    State(state): State<AppState>,
8541    AxumPath(run_id): AxumPath<String>,
8542) -> Response {
8543    let entry = {
8544        let reg = state.registry.lock().await;
8545        reg.find_by_run_id(&run_id).cloned()
8546    };
8547    entry.map_or_else(
8548        || error::not_found("run not found"),
8549        |e| build_metrics_response(&e),
8550    )
8551}
8552
8553fn build_metrics_response(entry: &RegistryEntry) -> Response {
8554    let languages: Vec<ApiLanguageRow> = entry
8555        .json_path
8556        .as_ref()
8557        .and_then(|p| read_json(p).ok())
8558        .map(|run| {
8559            run.totals_by_language
8560                .iter()
8561                .map(|l| ApiLanguageRow {
8562                    name: l.language.display_name().to_string(),
8563                    files: l.files,
8564                    code_lines: l.code_lines,
8565                    comment_lines: l.comment_lines,
8566                    blank_lines: l.blank_lines,
8567                    functions: l.functions,
8568                    classes: l.classes,
8569                    variables: l.variables,
8570                    imports: l.imports,
8571                })
8572                .collect()
8573        })
8574        .unwrap_or_default();
8575
8576    let s = &entry.summary;
8577    let coverage = if s.coverage_lines_found > 0 {
8578        let pct = |hit: u64, found: u64| -> f64 {
8579            if found == 0 {
8580                0.0
8581            } else {
8582                #[allow(clippy::cast_precision_loss)]
8583                let v = (hit as f64 / found as f64) * 100.0;
8584                (v * 10.0).round() / 10.0
8585            }
8586        };
8587        Some(ApiCoverageBlock {
8588            lines_found: s.coverage_lines_found,
8589            lines_hit: s.coverage_lines_hit,
8590            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8591            functions_found: s.coverage_functions_found,
8592            functions_hit: s.coverage_functions_hit,
8593            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8594            branches_found: s.coverage_branches_found,
8595            branches_hit: s.coverage_branches_hit,
8596            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8597        })
8598    } else {
8599        None
8600    };
8601    Json(ApiMetricsResponse {
8602        run_id: entry.run_id.clone(),
8603        timestamp: entry.timestamp_utc.to_rfc3339(),
8604        project: entry.project_label.clone(),
8605        summary: ApiSummaryPayload {
8606            files_analyzed: s.files_analyzed,
8607            files_skipped: s.files_skipped,
8608            code_lines: s.code_lines,
8609            comment_lines: s.comment_lines,
8610            blank_lines: s.blank_lines,
8611            total_physical_lines: s.total_physical_lines,
8612            functions: s.functions,
8613            classes: s.classes,
8614            variables: s.variables,
8615            imports: s.imports,
8616        },
8617        languages,
8618        coverage,
8619    })
8620    .into_response()
8621}
8622
8623// ── Project history API ───────────────────────────────────────────────────────
8624// Protected. Called by the wizard JS when the project path changes, so the UI
8625// can show a "scanned N times before" badge without a full page reload.
8626//
8627// GET /api/project-history?path=<project_root>
8628
8629#[derive(Deserialize)]
8630struct ProjectHistoryQuery {
8631    path: Option<String>,
8632}
8633
8634#[derive(Serialize)]
8635struct ProjectHistoryResponse {
8636    scan_count: usize,
8637    last_scan_id: Option<String>,
8638    last_scan_timestamp: Option<String>,
8639    last_scan_code_lines: Option<u64>,
8640    last_git_branch: Option<String>,
8641    last_git_commit: Option<String>,
8642}
8643
8644/// Return true if `entry` matches either an exact root path or an upload-staging
8645/// path with the same project name (needed because each upload gets a fresh UUID dir).
8646fn entry_matches_project(
8647    entry: &RegistryEntry,
8648    root_str: &str,
8649    upload_root: &str,
8650    upload_name_suffix: Option<&str>,
8651) -> bool {
8652    if entry.input_roots.iter().any(|r| r == root_str) {
8653        return true;
8654    }
8655    if let Some(suffix) = upload_name_suffix {
8656        return entry
8657            .input_roots
8658            .iter()
8659            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8660    }
8661    false
8662}
8663
8664async fn project_history_handler(
8665    State(state): State<AppState>,
8666    Query(query): Query<ProjectHistoryQuery>,
8667) -> Response {
8668    let path = query.path.unwrap_or_default();
8669    let resolved = resolve_input_path(&path);
8670    let root_str = resolved.to_string_lossy().replace('\\', "/");
8671
8672    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8673    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8674    // previous scans of the same project. Fall back to matching by project name within the
8675    // uploads staging directory so Scan History populates correctly across uploads.
8676    let upload_root = std::env::temp_dir()
8677        .join("oxide-sloc-uploads")
8678        .to_string_lossy()
8679        .replace('\\', "/");
8680    let upload_name_suffix: Option<String> =
8681        if state.server_mode && root_str.starts_with(&upload_root) {
8682            resolved
8683                .file_name()
8684                .and_then(|n| n.to_str())
8685                .map(|name| format!("/{name}"))
8686        } else {
8687            None
8688        };
8689    let suffix_ref = upload_name_suffix.as_deref();
8690
8691    let entries: Vec<_> = {
8692        let reg = state.registry.lock().await;
8693        reg.entries
8694            .iter()
8695            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8696            .cloned()
8697            .collect()
8698    };
8699    let scan_count = entries.len();
8700    let last = entries.first();
8701    let last_scan_id = last.map(|e| e.run_id.clone());
8702    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8703    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8704    let last_git_branch = last.and_then(|e| e.git_branch.clone());
8705    let last_git_commit = last.and_then(|e| e.git_commit.clone());
8706
8707    Json(ProjectHistoryResponse {
8708        scan_count,
8709        last_scan_id,
8710        last_scan_timestamp,
8711        last_scan_code_lines,
8712        last_git_branch,
8713        last_git_commit,
8714    })
8715    .into_response()
8716}
8717
8718// ── Metrics history API ───────────────────────────────────────────────────────
8719// Protected. Returns a JSON array of lightweight scan snapshots for plotting
8720// trend charts.
8721//
8722// GET /api/metrics/history?root=<path>&limit=<n>
8723
8724#[derive(Deserialize)]
8725struct MetricsHistoryQuery {
8726    root: Option<String>,
8727    limit: Option<usize>,
8728    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
8729    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
8730    submodule: Option<String>,
8731}
8732
8733#[derive(Serialize)]
8734struct MetricsSubmoduleLink {
8735    name: String,
8736    url: String,
8737}
8738
8739#[derive(Serialize)]
8740struct MetricsHistoryEntry {
8741    run_id: String,
8742    run_id_short: String,
8743    timestamp: String,
8744    commit: Option<String>,
8745    branch: Option<String>,
8746    tags: Vec<String>,
8747    nearest_tag: Option<String>,
8748    code_lines: u64,
8749    comment_lines: u64,
8750    blank_lines: u64,
8751    physical_lines: u64,
8752    files_analyzed: u64,
8753    files_skipped: u64,
8754    test_count: u64,
8755    project_label: String,
8756    html_url: Option<String>,
8757    has_pdf: bool,
8758    submodule_links: Vec<MetricsSubmoduleLink>,
8759    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
8760    #[serde(skip_serializing_if = "Option::is_none")]
8761    coverage_line_pct: Option<f64>,
8762}
8763
8764fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
8765    let mut links: Vec<MetricsSubmoduleLink> = vec![];
8766    let sub_dir = e
8767        .html_path
8768        .as_ref()
8769        .and_then(|p| p.parent())
8770        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
8771    let Some(dir) = sub_dir else { return links };
8772    let Ok(rd) = std::fs::read_dir(dir) else {
8773        return links;
8774    };
8775    for entry_res in rd.flatten() {
8776        let fname = entry_res.file_name();
8777        let fname_str = fname.to_string_lossy();
8778        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
8779            let stem = &fname_str[..fname_str.len() - 5];
8780            let display = stem[4..].replace('-', " ");
8781            links.push(MetricsSubmoduleLink {
8782                name: display,
8783                url: format!("/runs/{stem}/{}", e.run_id),
8784            });
8785        }
8786    }
8787    links.sort_by(|a, b| a.name.cmp(&b.name));
8788    links
8789}
8790
8791fn apply_submodule_filter(
8792    base: MetricsHistoryEntry,
8793    filter: &str,
8794    e: &sloc_core::history::RegistryEntry,
8795) -> Option<MetricsHistoryEntry> {
8796    let json_path = e.json_path.as_ref()?;
8797    let json_str = std::fs::read_to_string(json_path).ok()?;
8798    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
8799    let sub = run
8800        .submodule_summaries
8801        .iter()
8802        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
8803    let safe = sanitize_project_label(&sub.name);
8804    let artifact_key = format!("sub_{safe}");
8805    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
8806        || base.html_url.clone(),
8807        |run_dir| {
8808            let sub_path = run_dir.join(format!("{artifact_key}.html"));
8809            if sub_path.exists() {
8810                Some(format!("/runs/{artifact_key}/{}", e.run_id))
8811            } else {
8812                base.html_url.clone()
8813            }
8814        },
8815    );
8816
8817    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
8818    // basic SLOC totals, so test_count and coverage must be computed from file records.
8819    let sub_files: Vec<_> = run
8820        .per_file_records
8821        .iter()
8822        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8823        .collect();
8824    let test_count: u64 = sub_files
8825        .iter()
8826        .map(|r| r.raw_line_categories.test_count)
8827        .sum();
8828    #[allow(clippy::cast_precision_loss)]
8829    let coverage_line_pct: Option<f64> = {
8830        let found: u64 = sub_files
8831            .iter()
8832            .filter_map(|r| r.coverage.as_ref())
8833            .map(|c| u64::from(c.lines_found))
8834            .sum();
8835        let hit: u64 = sub_files
8836            .iter()
8837            .filter_map(|r| r.coverage.as_ref())
8838            .map(|c| u64::from(c.lines_hit))
8839            .sum();
8840        if found > 0 {
8841            let pct = (hit as f64 / found as f64) * 100.0;
8842            Some((pct * 10.0).round() / 10.0)
8843        } else {
8844            None
8845        }
8846    };
8847
8848    Some(MetricsHistoryEntry {
8849        code_lines: sub.code_lines,
8850        comment_lines: sub.comment_lines,
8851        blank_lines: sub.blank_lines,
8852        physical_lines: sub.total_physical_lines,
8853        files_analyzed: sub.files_analyzed,
8854        files_skipped: 0,
8855        test_count,
8856        html_url: sub_html_url,
8857        has_pdf: false,
8858        submodule_links: vec![],
8859        coverage_line_pct,
8860        ..base
8861    })
8862}
8863
8864#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
8865async fn api_metrics_history_handler(
8866    State(state): State<AppState>,
8867    Query(query): Query<MetricsHistoryQuery>,
8868) -> Response {
8869    let limit = query.limit.unwrap_or(50).min(500);
8870    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
8871
8872    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8873        let reg = state.registry.lock().await;
8874        reg.entries
8875            .iter()
8876            .filter(|e| {
8877                query.root.as_ref().is_none_or(|root| {
8878                    let resolved = resolve_input_path(root);
8879                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8880                    e.input_roots.iter().any(|r| r == &root_str)
8881                })
8882            })
8883            .take(limit)
8884            .cloned()
8885            .collect()
8886    };
8887
8888    let entries: Vec<MetricsHistoryEntry> = candidate_entries
8889        .into_iter()
8890        .filter_map(|e| {
8891            let tags = e
8892                .git_tags
8893                .as_deref()
8894                .map(|s| {
8895                    s.split(',')
8896                        .map(|t| t.trim().to_string())
8897                        .filter(|t| !t.is_empty())
8898                        .collect()
8899                })
8900                .unwrap_or_default();
8901            let html_url = e
8902                .html_path
8903                .as_ref()
8904                .filter(|p| p.exists())
8905                .map(|_| format!("/runs/html/{}", e.run_id));
8906            let nearest_tag = e.git_nearest_tag.clone();
8907            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
8908            let run_id_short: String = e
8909                .run_id
8910                .split('-')
8911                .next_back()
8912                .unwrap_or(&e.run_id)
8913                .chars()
8914                .take(7)
8915                .collect();
8916            let submodule_links = build_entry_submodule_links(&e);
8917            #[allow(clippy::cast_precision_loss)]
8918            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
8919                let pct = (e.summary.coverage_lines_hit as f64
8920                    / e.summary.coverage_lines_found as f64)
8921                    * 100.0;
8922                Some((pct * 10.0).round() / 10.0)
8923            } else {
8924                None
8925            };
8926            let base = MetricsHistoryEntry {
8927                run_id: e.run_id.clone(),
8928                run_id_short,
8929                timestamp: e.timestamp_utc.to_rfc3339(),
8930                commit: e.git_commit.clone(),
8931                branch: e.git_branch.clone(),
8932                tags,
8933                nearest_tag,
8934                code_lines: e.summary.code_lines,
8935                comment_lines: e.summary.comment_lines,
8936                blank_lines: e.summary.blank_lines,
8937                physical_lines: e.summary.total_physical_lines,
8938                files_analyzed: e.summary.files_analyzed,
8939                files_skipped: e.summary.files_skipped,
8940                test_count: e.summary.test_count,
8941                project_label: e.project_label.clone(),
8942                html_url,
8943                has_pdf,
8944                submodule_links,
8945                coverage_line_pct,
8946            };
8947            if let Some(ref filter) = submodule_filter {
8948                apply_submodule_filter(base, filter, &e)
8949            } else {
8950                Some(base)
8951            }
8952        })
8953        .collect();
8954
8955    Json(entries).into_response()
8956}
8957
8958/// One scan's code churn versus the previous scan of the same project.
8959#[derive(Serialize)]
8960struct ChurnEntry {
8961    run_id: String,
8962    added: i64,
8963    removed: i64,
8964    modified: i64,
8965    unmodified: i64,
8966}
8967
8968// GET /api/metrics/churn?root=<path>&limit=<n>
8969// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
8970// comparing each scan to the previous scan of the same project. Loads per-file JSON
8971// artifacts, so it is intended for export-time use rather than every page load.
8972async fn api_metrics_churn_handler(
8973    State(state): State<AppState>,
8974    Query(query): Query<MetricsHistoryQuery>,
8975) -> Response {
8976    let limit = query.limit.unwrap_or(200).min(500);
8977    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
8978        let reg = state.registry.lock().await;
8979        reg.entries
8980            .iter()
8981            .filter(|e| {
8982                query.root.as_ref().is_none_or(|root| {
8983                    let resolved = resolve_input_path(root);
8984                    let root_str = resolved.to_string_lossy().replace('\\', "/");
8985                    e.input_roots.iter().any(|r| r == &root_str)
8986                })
8987            })
8988            .take(limit)
8989            .cloned()
8990            .collect()
8991    };
8992    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
8993        std::collections::HashMap::new();
8994    for e in candidate_entries {
8995        by_project
8996            .entry(e.project_label.clone())
8997            .or_default()
8998            .push(e);
8999    }
9000    let mut out: Vec<ChurnEntry> = Vec::new();
9001    for (_proj, mut entries) in by_project {
9002        entries.sort_by_key(|e| e.timestamp_utc);
9003        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9004        for e in &entries {
9005            let curr = e
9006                .json_path
9007                .as_ref()
9008                .and_then(|path| sloc_core::read_json(path).ok());
9009            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9010                let cmp = sloc_core::compute_delta(prev, cur);
9011                out.push(ChurnEntry {
9012                    run_id: e.run_id.clone(),
9013                    added: sum_added_code_lines(&cmp),
9014                    removed: sum_removed_code_lines(&cmp),
9015                    modified: sum_modified_code_lines(&cmp),
9016                    unmodified: sum_unmodified_code_lines(&cmp),
9017                });
9018            } else {
9019                out.push(ChurnEntry {
9020                    run_id: e.run_id.clone(),
9021                    added: 0,
9022                    removed: 0,
9023                    modified: 0,
9024                    unmodified: 0,
9025                });
9026            }
9027            if curr.is_some() {
9028                prev_run = curr;
9029            }
9030        }
9031    }
9032    Json(out).into_response()
9033}
9034
9035// GET /api/metrics/submodules?root=<path>
9036// Returns the union of distinct submodule names found across all saved scan JSON artifacts
9037// for the given project root (or all roots if omitted).
9038#[derive(Deserialize)]
9039struct MetricsSubmodulesQuery {
9040    root: Option<String>,
9041}
9042
9043#[derive(Serialize)]
9044struct SubmoduleEntry {
9045    name: String,
9046    relative_path: String,
9047}
9048
9049async fn api_metrics_submodules_handler(
9050    State(state): State<AppState>,
9051    Query(query): Query<MetricsSubmodulesQuery>,
9052) -> Response {
9053    let json_paths: Vec<std::path::PathBuf> = {
9054        let reg = state.registry.lock().await;
9055        reg.entries
9056            .iter()
9057            .filter(|e| {
9058                query.root.as_ref().is_none_or(|root| {
9059                    let resolved = resolve_input_path(root);
9060                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9061                    e.input_roots.iter().any(|r| r == &root_str)
9062                })
9063            })
9064            .filter_map(|e| e.json_path.clone())
9065            .collect()
9066    };
9067
9068    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9069    let mut result: Vec<SubmoduleEntry> = Vec::new();
9070
9071    for path in &json_paths {
9072        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9073            continue;
9074        };
9075        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9076            continue;
9077        };
9078        for sub in &run.submodule_summaries {
9079            if seen.insert(sub.name.clone()) {
9080                result.push(SubmoduleEntry {
9081                    name: sub.name.clone(),
9082                    relative_path: sub.relative_path.clone(),
9083                });
9084            }
9085        }
9086    }
9087
9088    result.sort_by(|a, b| a.name.cmp(&b.name));
9089    Json(result).into_response()
9090}
9091
9092// ── CI ingest endpoint ────────────────────────────────────────────────────────
9093// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
9094// server stores and displays results without cloning or scanning anything itself.
9095//
9096// POST /api/ingest?label=<optional_display_name>
9097// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
9098// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
9099
9100#[derive(Deserialize)]
9101struct IngestQuery {
9102    label: Option<String>,
9103}
9104
9105#[derive(Serialize)]
9106struct IngestResponse {
9107    run_id: String,
9108    view_url: String,
9109}
9110
9111async fn api_ingest_handler(
9112    State(state): State<AppState>,
9113    Query(q): Query<IngestQuery>,
9114    Json(run): Json<sloc_core::AnalysisRun>,
9115) -> Response {
9116    let label = q.label.unwrap_or_else(|| {
9117        run.input_roots
9118            .first()
9119            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9120    });
9121
9122    let label_for_task = label.clone();
9123    let result = tokio::task::spawn_blocking(move || {
9124        let html = render_html(&run)?;
9125        let run_id = run.tool.run_id.clone();
9126        let run_id_safe = run_id.len() <= 128
9127            && !run_id.is_empty()
9128            && run_id
9129                .chars()
9130                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9131        if !run_id_safe {
9132            anyhow::bail!(
9133                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9134            );
9135        }
9136        let project_label = sanitize_project_label(&label_for_task);
9137        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9138        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9139            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9140            _ => project_label,
9141        };
9142        let (artifacts, _pending_pdf) = persist_run_artifacts(
9143            &run,
9144            &html,
9145            &output_dir,
9146            &label_for_task,
9147            &file_stem,
9148            RunResultContext::default(),
9149        )?;
9150        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9151    })
9152    .await;
9153
9154    match result {
9155        Ok(Ok((run_id, artifacts, run))) => {
9156            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9157            (
9158                StatusCode::CREATED,
9159                Json(IngestResponse {
9160                    view_url: format!("/view-reports?run_id={run_id}"),
9161                    run_id,
9162                }),
9163            )
9164                .into_response()
9165        }
9166        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9167        Err(e) => error::internal(&format!("{e}")),
9168    }
9169}
9170
9171// ── Multi-compare page ────────────────────────────────────────────────────────
9172// GET /multi-compare?runs=id1,id2,id3,...
9173
9174fn html_escape(s: &str) -> String {
9175    s.replace('&', "&amp;")
9176        .replace('<', "&lt;")
9177        .replace('>', "&gt;")
9178        .replace('"', "&quot;")
9179}
9180
9181#[allow(clippy::cast_precision_loss)]
9182fn fmt_num(n: i64) -> String {
9183    let a = n.unsigned_abs();
9184    if a >= 1_000_000 {
9185        let v = n as f64 / 1_000_000.0;
9186        let s = format!("{v:.1}");
9187        format!("{}M", s.trim_end_matches(".0"))
9188    } else if a >= 10_000 {
9189        let v = n as f64 / 1_000.0;
9190        let s = format!("{v:.1}");
9191        format!("{}K", s.trim_end_matches(".0"))
9192    } else {
9193        let sign = if n < 0 { "-" } else { "" };
9194        if a < 1_000 {
9195            return format!("{sign}{a}");
9196        }
9197        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9198    }
9199}
9200
9201fn fmt_comma(n: i64) -> String {
9202    let sign = if n < 0 { "-" } else { "" };
9203    let a = n.unsigned_abs();
9204    if a < 1_000 {
9205        return format!("{sign}{a}");
9206    }
9207    let s = a.to_string();
9208    let bytes = s.as_bytes();
9209    let len = bytes.len();
9210    let mut out = String::with_capacity(len + len / 3);
9211    for (i, &b) in bytes.iter().enumerate() {
9212        if i > 0 && (len - i).is_multiple_of(3) {
9213            out.push(',');
9214        }
9215        out.push(b as char);
9216    }
9217    format!("{sign}{out}")
9218}
9219
9220/// Insert thousands separators into the integer portion of a number's textual form.
9221///
9222/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9223/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9224/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9225/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9226fn group_thousands(s: &str) -> String {
9227    let (sign, rest) = match s.as_bytes().first() {
9228        Some(b'-') => ("-", &s[1..]),
9229        Some(b'+') => ("+", &s[1..]),
9230        _ => ("", s),
9231    };
9232    let (int_part, frac_part) = match rest.split_once('.') {
9233        Some((i, f)) => (i, Some(f)),
9234        None => (rest, None),
9235    };
9236    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9237        return s.to_string();
9238    }
9239    let bytes = int_part.as_bytes();
9240    let len = bytes.len();
9241    let mut grouped = String::with_capacity(len + len / 3);
9242    for (i, &b) in bytes.iter().enumerate() {
9243        if i > 0 && (len - i).is_multiple_of(3) {
9244            grouped.push(',');
9245        }
9246        grouped.push(b as char);
9247    }
9248    frac_part.map_or_else(
9249        || format!("{sign}{grouped}"),
9250        |f| format!("{sign}{grouped}.{f}"),
9251    )
9252}
9253
9254/// Custom Askama filters available to templates in this crate.
9255mod filters {
9256    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9257    // (a `&self` `execute` method returning `Result`), not on our own source.
9258    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9259    use askama::{Result, Values};
9260
9261    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9262    ///
9263    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9264    /// (dashes, "No prior scan", etc.) passes through untouched.
9265    #[askama::filter_fn]
9266    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9267        Ok(super::group_thousands(&value.to_string()))
9268    }
9269}
9270
9271#[derive(Deserialize, Default)]
9272struct MultiCompareQuery {
9273    runs: Option<String>,
9274    /// "super" to show only super-repo files (exclude all submodule files)
9275    scope: Option<String>,
9276    /// Submodule name to narrow the comparison to one submodule
9277    sub: Option<String>,
9278}
9279
9280#[allow(clippy::too_many_lines)]
9281async fn multi_compare_handler(
9282    State(state): State<AppState>,
9283    Query(params): Query<MultiCompareQuery>,
9284    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9285) -> impl IntoResponse {
9286    let run_ids: Vec<String> = params
9287        .runs
9288        .as_deref()
9289        .unwrap_or("")
9290        .split(',')
9291        .map(|s| s.trim().to_string())
9292        .filter(|s| !s.is_empty())
9293        .collect();
9294
9295    if run_ids.len() < 2 {
9296        return Html(
9297            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9298             <a href=\"/compare-scans\">Go back</a></p>",
9299        )
9300        .into_response();
9301    }
9302    if run_ids.len() > 20 {
9303        return Html(
9304            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9305             at once. <a href=\"/compare-scans\">Go back</a></p>",
9306        )
9307        .into_response();
9308    }
9309
9310    // Look up each run_id in the registry.
9311    let entries: Vec<Option<RegistryEntry>> = {
9312        let reg = state.registry.lock().await;
9313        run_ids
9314            .iter()
9315            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9316            .collect()
9317    };
9318
9319    for (i, entry) in entries.iter().enumerate() {
9320        if entry.is_none() {
9321            let html = format!(
9322                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9323                 found. <a href=\"/compare-scans\">Go back</a></p>",
9324                run_ids[i]
9325            );
9326            return Html(html).into_response();
9327        }
9328    }
9329
9330    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9331
9332    for entry in &entries {
9333        if entry.json_path.is_none() {
9334            let html = format!(
9335                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9336                 JSON data — re-run the analysis to enable comparison. \
9337                 <a href=\"/compare-scans\">Go back</a></p>",
9338                entry.run_id
9339            );
9340            return Html(html).into_response();
9341        }
9342    }
9343
9344    // Sort chronologically.
9345    entries.sort_by_key(|e| e.timestamp_utc);
9346
9347    // Load JSON for each entry.
9348    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9349    for entry in &entries {
9350        let path = entry.json_path.as_ref().unwrap();
9351        match read_json(path) {
9352            Ok(r) => runs.push(r),
9353            Err(e) => {
9354                let html = format!(
9355                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9356                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9357                    entry.run_id
9358                );
9359                return Html(html).into_response();
9360            }
9361        }
9362    }
9363
9364    // Collect submodule names from all runs.
9365    let all_sub_names: Vec<String> = {
9366        let mut set = std::collections::BTreeSet::new();
9367        for r in &runs {
9368            for s in &r.submodule_summaries {
9369                set.insert(s.name.clone());
9370            }
9371        }
9372        set.into_iter().collect()
9373    };
9374    let has_submodule_data = !all_sub_names.is_empty();
9375    let active_submodule = params.sub.clone();
9376    let super_scope_active = params.scope.as_deref() == Some("super");
9377
9378    // Narrow per_file_records when a scope is active, then recompute totals.
9379    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9380
9381    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9382    let project_label = entries
9383        .first()
9384        .map_or("", |e| e.project_label.as_str())
9385        .to_string();
9386    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9387    let multi = compute_multi_delta(&run_refs);
9388    let html = multi_compare_page(
9389        &multi,
9390        &project_label,
9391        env!("CARGO_PKG_VERSION"),
9392        &csp_nonce,
9393        has_submodule_data,
9394        &all_sub_names,
9395        &runs_csv,
9396        super_scope_active,
9397        active_submodule.as_deref(),
9398        &entries,
9399    );
9400    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9401    // copy after a rebuild would silently mask UI fixes.
9402    (
9403        [(axum::http::header::CACHE_CONTROL, "no-store")],
9404        Html(html),
9405    )
9406        .into_response()
9407}
9408
9409const fn multi_delta_class(n: i64) -> &'static str {
9410    match n {
9411        1.. => "pos",
9412        ..=-1 => "neg",
9413        0 => "zero",
9414    }
9415}
9416
9417fn multi_fmt_delta(n: i64) -> String {
9418    if n > 0 {
9419        format!("+{n}")
9420    } else {
9421        format!("{n}")
9422    }
9423}
9424
9425/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9426fn js_escape(s: &str) -> String {
9427    use std::fmt::Write as _;
9428    let mut out = String::with_capacity(s.len() + 2);
9429    for c in s.chars() {
9430        match c {
9431            '"' => out.push_str("\\\""),
9432            '\\' => out.push_str("\\\\"),
9433            '\n' => out.push_str("\\n"),
9434            '\r' => out.push_str("\\r"),
9435            '\t' => out.push_str("\\t"),
9436            c if (c as u32) < 0x20 => {
9437                let _ = write!(out, "\\u{:04x}", c as u32);
9438            }
9439            c => out.push(c),
9440        }
9441    }
9442    out
9443}
9444
9445/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9446fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9447    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9448        return (
9449            "&mdash;".to_string(),
9450            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9451        );
9452    };
9453    let cd = entry
9454        .git_commit_date
9455        .as_deref()
9456        .and_then(fmt_git_date)
9457        .unwrap_or_else(|| "&mdash;".to_string());
9458    let au = entry.git_author.as_deref().map_or_else(
9459        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9460        |a| {
9461            format!(
9462                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9463                 <span class=\"cmp-author-handle\"></span></span>",
9464                html_escape(a)
9465            )
9466        },
9467    );
9468    (cd, au)
9469}
9470
9471/// Render the scope badge chip for a scan card header.
9472fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9473    active_sub.map_or_else(
9474        || {
9475            if super_scope_active {
9476                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9477            } else {
9478                "<span class=\"mc-scope-tag mc-scope-full\">\
9479                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9480                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9481                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9482                 <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>\
9483                 </svg> Full scan</span>"
9484                    .to_string()
9485            }
9486        },
9487        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9488    )
9489}
9490
9491/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9492fn build_mc_scan_strip(
9493    multi: &MultiScanComparison,
9494    entries: &[RegistryEntry],
9495    n: usize,
9496    is_many: bool,
9497    active_sub: Option<&str>,
9498    super_scope_active: bool,
9499    project_label: &str,
9500) -> String {
9501    use std::fmt::Write as _;
9502    let mut scan_strip = String::new();
9503    for (i, pt) in multi.points.iter().enumerate() {
9504        let ts_ms = pt.timestamp.timestamp_millis();
9505        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9506        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9507        let branch = pt.git_branch.as_deref().unwrap_or("");
9508        let report_link = format!("/runs/html/{}", pt.run_id);
9509        let branch_html = if branch.is_empty() {
9510            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9511        } else {
9512            format!(
9513                "<span class=\"mc-card-branch\">{}</span>",
9514                html_escape(branch)
9515            )
9516        };
9517        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9518        let tags_html = pt
9519            .git_tags
9520            .as_deref()
9521            .filter(|t| !t.is_empty())
9522            .map(|t| {
9523                let chips = t
9524                    .split(',')
9525                    .filter(|s| !s.is_empty())
9526                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9527                    .collect::<Vec<_>>()
9528                    .join(" ");
9529                format!(
9530                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9531                     <span class=\"mc-row-val\">{chips}</span></div>"
9532                )
9533            })
9534            .unwrap_or_default();
9535        let nearest = pt
9536            .git_nearest_tag
9537            .as_deref()
9538            .map(|t| format!("near {}", html_escape(t)))
9539            .unwrap_or_default();
9540        let arrow = if i < n - 1 && !is_many {
9541            "<div class='mc-arrow'>&#8594;</div>"
9542        } else {
9543            ""
9544        };
9545        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9546        let nearest_html = if nearest.is_empty() {
9547            String::new()
9548        } else {
9549            format!(
9550                "<span class=\"mc-card-nearest-wrap\">\
9551                 <span class=\"mc-card-nearest\">{nearest}</span>\
9552                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9553                 </span>"
9554            )
9555        };
9556        write!(
9557            scan_strip,
9558            r#"<div class="mc-card">
9559              <div class="mc-card-header">
9560                <div class="mc-card-num">Scan {num}</div>
9561                <div class="mc-card-project-col">
9562                  <div class="mc-card-project">{project_label}</div>
9563                  {scope_badge}
9564                </div>
9565              </div>
9566              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9567              <div class="mc-card-rows">
9568                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9569                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9570                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9571                <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>
9572                {tags_html}
9573              </div>
9574              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9575            </div>{arrow}"#,
9576            num = i + 1,
9577            commit = html_escape(commit),
9578            commit_date = commit_date_html,
9579            ts_ms = ts_ms,
9580            code = fmt_num(pt.code_lines),
9581            scope_badge = scope_badge,
9582            nearest_html = nearest_html,
9583        )
9584        .unwrap();
9585    }
9586    scan_strip
9587}
9588
9589/// Build the metric progression table (thead + tbody) for multi-compare.
9590#[allow(clippy::too_many_lines)]
9591fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9592    use std::fmt::Write as _;
9593    struct MetricRow<'a> {
9594        label: &'a str,
9595        values: Vec<i64>,
9596        seq_deltas: Vec<i64>,
9597        net_delta: i64,
9598    }
9599    let rows: Vec<MetricRow<'_>> = vec![
9600        MetricRow {
9601            label: "Code Lines",
9602            values: multi.points.iter().map(|p| p.code_lines).collect(),
9603            seq_deltas: multi
9604                .sequential_deltas
9605                .iter()
9606                .map(|d| d.summary.code_lines_delta)
9607                .collect(),
9608            net_delta: multi.total_delta.code_lines_delta,
9609        },
9610        MetricRow {
9611            label: "Files Analyzed",
9612            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9613            seq_deltas: multi
9614                .sequential_deltas
9615                .iter()
9616                .map(|d| d.summary.files_analyzed_delta)
9617                .collect(),
9618            net_delta: multi.total_delta.files_analyzed_delta,
9619        },
9620        MetricRow {
9621            label: "Comment Lines",
9622            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9623            seq_deltas: multi
9624                .sequential_deltas
9625                .iter()
9626                .map(|d| d.summary.comment_lines_delta)
9627                .collect(),
9628            net_delta: multi.total_delta.comment_lines_delta,
9629        },
9630        MetricRow {
9631            label: "Blank Lines",
9632            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9633            seq_deltas: multi
9634                .sequential_deltas
9635                .iter()
9636                .map(|d| d.summary.blank_lines_delta)
9637                .collect(),
9638            net_delta: multi.total_delta.blank_lines_delta,
9639        },
9640        MetricRow {
9641            label: "Tests",
9642            values: multi.points.iter().map(|p| p.test_count).collect(),
9643            seq_deltas: multi
9644                .points
9645                .windows(2)
9646                .map(|pts| pts[1].test_count - pts[0].test_count)
9647                .collect(),
9648            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9649                - multi.points.first().map_or(0, |f| f.test_count),
9650        },
9651    ];
9652    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9653    for i in 0..n {
9654        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9655        if i < n - 1 {
9656            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9657        }
9658    }
9659    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9660    let mut metrics_tbody = String::new();
9661    for row in &rows {
9662        metrics_tbody.push_str("<tr>");
9663        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9664        for i in 0..n {
9665            write!(
9666                metrics_tbody,
9667                "<td class='mc-val-col'>{}</td>",
9668                fmt_comma(row.values[i])
9669            )
9670            .unwrap();
9671            if i < n - 1 {
9672                let d = row.seq_deltas[i];
9673                write!(
9674                    metrics_tbody,
9675                    "<td class='mc-delta-col {cls}'>{val}</td>",
9676                    cls = multi_delta_class(d),
9677                    val = multi_fmt_delta(d)
9678                )
9679                .unwrap();
9680            }
9681        }
9682        let nd = row.net_delta;
9683        write!(
9684            metrics_tbody,
9685            "<td class='mc-net-col {cls}'>{val}</td>",
9686            cls = multi_delta_class(nd),
9687            val = multi_fmt_delta(nd)
9688        )
9689        .unwrap();
9690        metrics_tbody.push_str("</tr>");
9691    }
9692    (metrics_thead, metrics_tbody)
9693}
9694
9695/// Build the JS-embeddable points JSON array for the multi-compare chart.
9696fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9697    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9698    for (i, pt) in multi.points.iter().enumerate() {
9699        let commit = pt.git_commit.as_deref().unwrap_or("");
9700        let branch = pt.git_branch.as_deref().unwrap_or("");
9701        let tags = pt.git_tags.as_deref().unwrap_or("");
9702        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9703        let scanned_ms = pt.timestamp.timestamp_millis();
9704        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9705        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9706        let commit_date = entry
9707            .and_then(|e| e.git_commit_date.as_deref())
9708            .and_then(fmt_git_date)
9709            .unwrap_or_default();
9710        let author = entry
9711            .and_then(|e| e.git_author.as_deref())
9712            .unwrap_or("")
9713            .to_string();
9714        let cov = pt
9715            .coverage_line_pct
9716            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9717        parts.push(format!(
9718            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}}}"#,
9719            run_id = js_escape(&pt.run_id),
9720            commit = js_escape(commit),
9721            branch = js_escape(branch),
9722            tags = js_escape(tags),
9723            nearest = js_escape(nearest),
9724            commit_date = js_escape(&commit_date),
9725            author = js_escape(&author),
9726            scanned = js_escape(&scanned),
9727            code = pt.code_lines,
9728            comments = pt.comment_lines,
9729            blank = pt.blank_lines,
9730            files = pt.files_analyzed,
9731            tests = pt.test_count,
9732        ));
9733    }
9734    format!("[{}]", parts.join(","))
9735}
9736
9737/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
9738fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
9739    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
9740    for row in &multi.file_matrix {
9741        let lang = row.language.as_deref().unwrap_or("");
9742        let codes: Vec<String> = row
9743            .code_per_scan
9744            .iter()
9745            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9746            .collect();
9747        let deltas: Vec<String> = row
9748            .code_delta_per_scan
9749            .iter()
9750            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
9751            .collect();
9752        parts.push(format!(
9753            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
9754            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
9755            status = row.overall_status,
9756            codes = codes.join(","),
9757            deltas = deltas.join(","),
9758            total = row.total_code_delta,
9759        ));
9760    }
9761    format!("[{}]", parts.join(","))
9762}
9763
9764/// Build the column header cells for the file-matrix table.
9765fn build_mc_file_col_headers(n: usize) -> String {
9766    use std::fmt::Write as _;
9767    let mut out = String::new();
9768    for i in 0..n {
9769        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
9770        if i < n - 1 {
9771            write!(
9772                out,
9773                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
9774                i + 2
9775            )
9776            .unwrap();
9777        }
9778    }
9779    out
9780}
9781
9782/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
9783fn build_mc_scope_bar(
9784    has_submodule_data: bool,
9785    sub_names: &[String],
9786    runs_csv: &str,
9787    active_sub: Option<&str>,
9788    super_scope_active: bool,
9789) -> String {
9790    use std::fmt::Write as _;
9791    if !has_submodule_data {
9792        return String::new();
9793    }
9794    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
9795    let full_active = active_sub.is_none() && !super_scope_active;
9796    let mut bar = format!(
9797        r#"<div class="submod-scope-bar">
9798  <span class="submod-scope-label">
9799    <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>
9800    Scope:
9801  </span>
9802  <div class="submod-scope-divider"></div>
9803  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
9804  <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>"#,
9805        full_cls = if full_active { " active" } else { "" },
9806        super_cls = if super_scope_active { " active" } else { "" },
9807    );
9808    for s in sub_names {
9809        let is_active = active_sub == Some(s.as_str());
9810        write!(
9811            bar,
9812            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
9813            cls = if is_active { " active" } else { "" },
9814            name_enc = html_escape(s),
9815            name_esc = html_escape(s),
9816        )
9817        .unwrap();
9818    }
9819    bar.push_str("\n</div>");
9820    bar
9821}
9822
9823/// Build the scope-description label shown in the page subtitle.
9824fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
9825    active_sub.map_or_else(
9826        || {
9827            if super_scope_active {
9828                "Super-repo only &mdash; ".to_string()
9829            } else {
9830                String::new()
9831            }
9832        },
9833        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
9834    )
9835}
9836
9837#[allow(clippy::too_many_lines)]
9838#[allow(clippy::too_many_arguments)]
9839fn multi_compare_page(
9840    multi: &MultiScanComparison,
9841    project_label: &str,
9842    version: &str,
9843    csp_nonce: &str,
9844    has_submodule_data: bool,
9845    sub_names: &[String],
9846    runs_csv: &str,
9847    super_scope_active: bool,
9848    active_sub: Option<&str>,
9849    entries: &[RegistryEntry],
9850) -> String {
9851    let n = multi.points.len();
9852    let is_many = n > 4;
9853    let mc_strip_class = if is_many {
9854        "mc-strip mc-strip-grid"
9855    } else {
9856        "mc-strip"
9857    };
9858
9859    // ── Scan strip cards ──────────────────────────────────────────────────────
9860    let scan_strip = build_mc_scan_strip(
9861        multi,
9862        entries,
9863        n,
9864        is_many,
9865        active_sub,
9866        super_scope_active,
9867        project_label,
9868    );
9869
9870    // ── Summary metrics table ─────────────────────────────────────────────────
9871    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
9872
9873    // ── Chart data and table helpers ──────────────────────────────────────────
9874    let points_json = build_mc_points_json(multi, entries);
9875    let file_matrix_json = build_mc_file_matrix_json(multi);
9876
9877    // Counts for filter tabs
9878    let files_modified = multi
9879        .file_matrix
9880        .iter()
9881        .filter(|f| f.overall_status == "modified")
9882        .count();
9883    let files_added = multi
9884        .file_matrix
9885        .iter()
9886        .filter(|f| f.overall_status == "added")
9887        .count();
9888    let files_removed = multi
9889        .file_matrix
9890        .iter()
9891        .filter(|f| f.overall_status == "removed")
9892        .count();
9893    let files_unchanged = multi
9894        .file_matrix
9895        .iter()
9896        .filter(|f| f.overall_status == "unchanged")
9897        .count();
9898    let total_files = multi.file_matrix.len();
9899
9900    let file_col_headers = build_mc_file_col_headers(n);
9901    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
9902    let scope_bar_html = build_mc_scope_bar(
9903        has_submodule_data,
9904        sub_names,
9905        runs_csv,
9906        active_sub,
9907        super_scope_active,
9908    );
9909    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
9910    let toast_assets = sloc_toast_assets(csp_nonce);
9911
9912    format!(
9913        r#"<!doctype html>
9914<html lang="en">
9915<head>
9916  <meta charset="utf-8">
9917  <meta name="viewport" content="width=device-width, initial-scale=1">
9918  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
9919  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9920  <style nonce="{csp_nonce}">
9921    :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;}}
9922    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
9923    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
9924    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;}}
9925    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9926    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
9927    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
9928    .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;}}
9929    @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));}}}}
9930    .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);}}
9931    .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;}}
9932    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
9933    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
9934    @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;}}}}
9935    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
9936    .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));}}
9937    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
9938    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
9939    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
9940    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
9941    .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;}}
9942    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9943    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
9944    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
9945    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
9946    .nav-dropdown{{position:relative;display:inline-flex;}}
9947    .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;}}
9948    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
9949    .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;}}
9950    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
9951    .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);}}
9952    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
9953    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
9954    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
9955    body:not(.dark-theme) .icon-sun{{display:none;}}
9956    body.dark-theme .icon-moon{{display:none;}}
9957    .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;}}
9958    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
9959    .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);}}
9960    .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;}}
9961    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
9962    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
9963    .settings-modal-body{{padding:14px 16px 16px;}}
9964    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
9965    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
9966    .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;}}
9967    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
9968    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
9969    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
9970    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
9971    .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;}}
9972    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
9973    .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;}}
9974    .btn-back:hover{{background:var(--line);}}
9975    .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;}}
9976    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;}}
9977    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
9978    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
9979    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
9980    .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;}}
9981    .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;}}
9982    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
9983    .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;}}
9984    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
9985    body.dark-theme .mc-card{{background:var(--surface-2);}}
9986    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
9987    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
9988    .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%;}}
9989    .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;}}
9990    .mc-card-commit:hover{{color:var(--oxide);}}
9991    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
9992    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
9993    .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;}}
9994    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
9995    .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;}}
9996    .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;}}
9997    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
9998    .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;}}
9999    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10000    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10001    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10002    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10003    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10004    .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);}}
10005    .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);}}
10006    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10007    .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;}}
10008    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10009    .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;}}
10010    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10011    .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;}}
10012    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10013    .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;}}
10014    .submod-scope-btn:hover{{background:var(--line);}}
10015    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10016    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10017    .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;}}
10018    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10019    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10020    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10021    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10022    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10023    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10024    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10025    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10026    .metrics-table .pos{{color:var(--pos);}}
10027    .metrics-table .neg{{color:var(--neg);}}
10028    .metrics-table .zero{{color:var(--muted);}}
10029    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10030    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10031    .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;}}
10032    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10033    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10034    .chart-wrap{{width:100%;overflow-x:auto;}}
10035    #mc-chart{{display:block;width:100%;}}
10036    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10037    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10038    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10039    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10040    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10041    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10042    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10043    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10044    .ic-card-h2-row .ic-card-h2{{margin:0;}}
10045    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10046    .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;}}
10047    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10048    .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;}}
10049    .ic-svg-modal-ov.open{{display:flex;}}
10050    .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);}}
10051    .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);}}
10052    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10053    .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;}}
10054    .ic-svg-modal-close:hover{{background:var(--line);}}
10055    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10056    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10057    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10058    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10059    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10060    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10061    #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;}}
10062    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10063    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10064    .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;}}
10065    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10066    .tab-btn:hover:not(.active){{background:var(--line);}}
10067    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10068    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10069    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10070    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10071    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10072    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10073    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10074    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10075    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10076    .table-wrap{{width:100%;overflow-x:auto;}}
10077    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10078    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10079    #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;}}
10080    #file-table th.left,#file-table td.left{{text-align:left;}}
10081    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10082    .file-delta-col{{color:var(--muted);font-size:11px;}}
10083    .file-net-col{{font-weight:800;}}
10084    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10085    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10086    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10087    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10088    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10089    .status-badge.modified{{background:#fff2d8;color:#926000;}}
10090    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10091    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10092    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10093    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10094    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10095    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10096    tr.row-added td{{background:rgba(26,143,71,0.04);}}
10097    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10098    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10099    tr.row-unchanged td{{color:var(--muted);}}
10100    tr.row-unchanged .status-badge{{opacity:.65;}}
10101    .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;}}
10102    .absent{{color:var(--muted);font-style:italic;}}
10103    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10104    .pagination-info{{font-size:12px;color:var(--muted);}}
10105    .pagination-btns{{display:flex;gap:5px;}}
10106    .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;}}
10107    .pg-btn:hover:not(:disabled){{background:var(--line);}}
10108    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10109    .pg-btn:disabled{{opacity:.35;cursor:default;}}
10110    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;}}
10111    .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;}}
10112    .export-btn:hover{{background:var(--line);}}
10113    .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;}}
10114    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10115    .site-footer a{{color:var(--muted);}}
10116    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;}}
10117    body.pdf-mode{{background:#fff!important;}}
10118    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10119    .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;}}
10120    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10121    .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;}}
10122    .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;}}
10123    .mc-modal-title{{font-size:18px;font-weight:800;}}
10124    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10125    .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;}}
10126    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10127    .mc-modal-body{{padding:18px 22px;}}
10128    .mc-modal-sec{{margin-bottom:20px;}}
10129    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10130    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10131    .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;}}
10132    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10133    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10134    .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;}}
10135    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10136    .mc-modal-row:last-child{{border-bottom:none;}}
10137    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10138    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10139    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10140    .mc-modal-val a:hover{{text-decoration:underline;}}
10141    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10142    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10143    .mc-modal-stat[data-tip]{{cursor:help;}}
10144    #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);}}
10145    .mc-card{{cursor:pointer;}}
10146    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10147  </style>
10148</head>
10149<body>
10150  {loading_overlay}
10151  <div class="background-watermarks" aria-hidden="true">
10152    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10153    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10154    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10155    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10156    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10157    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10158  </div>
10159  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10160  <div class="top-nav">
10161    <div class="top-nav-inner">
10162      <a class="brand" href="/">
10163        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10164        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10165      </a>
10166      <div class="nav-right">
10167        <a class="nav-pill" href="/">Home</a>
10168        <div class="nav-dropdown">
10169          <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>
10170          <div class="nav-dropdown-menu">
10171            <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>
10172          </div>
10173        </div>
10174        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10175        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10176        <div class="nav-dropdown">
10177          <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>
10178          <div class="nav-dropdown-menu">
10179            <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>
10180          </div>
10181        </div>
10182        <div class="server-status-wrap" id="server-status-wrap">
10183          <div class="nav-pill server-online-pill" id="server-status-pill">
10184            <span class="status-dot" id="status-dot"></span>
10185            <span id="server-status-label">Server</span>
10186            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10187          </div>
10188          <div class="server-status-tip">
10189            OxideSLOC is running &mdash; accessible on your network.
10190            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10191          </div>
10192        </div>
10193        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10194          <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>
10195        </button>
10196        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10197          <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>
10198          <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>
10199        </button>
10200      </div>
10201    </div>
10202  </div>
10203
10204  <div class="page">
10205    <!-- Hero header -->
10206    <div class="mc-hero">
10207      <div class="mc-hero-header">
10208        <div>
10209          <div class="mc-title">Multi-Scan Timeline</div>
10210          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10211          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10212        </div>
10213        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10214          <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>
10215          <div class="export-group" id="mc-top-export-group">
10216            <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>
10217            <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>
10218          </div>
10219        </div>
10220      </div>
10221      {scope_bar_html}
10222      <!-- Scan strip -->
10223      <div class="{mc_strip_class}">{scan_strip}</div>
10224    </div>
10225
10226    <!-- Summary metrics table -->
10227    <div class="panel">
10228      <div class="panel-title">Metric Progression</div>
10229      <div class="table-wrap">
10230        <table class="metrics-table">
10231          <thead>{metrics_thead}</thead>
10232          <tbody>{metrics_tbody}</tbody>
10233        </table>
10234      </div>
10235    </div>
10236
10237    <!-- Scan Charts -->
10238    <div class="panel" id="mc-charts-panel">
10239      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10240      <div class="ic-grid">
10241        <!-- Timeline line chart — spans full width -->
10242        <div class="ic-card" style="grid-column:span 2">
10243          <div class="ic-card-h2-row">
10244            <span class="ic-card-h2">Timeline</span>
10245            <div class="chart-toolbar" style="margin:0">
10246              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10247              <button class="chart-metric-btn" data-metric="files">Files</button>
10248              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10249              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10250              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10251            </div>
10252          </div>
10253          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10254        </div>
10255        <!-- Code Metrics: Scan 1 vs Latest -->
10256        <div class="ic-card">
10257          <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>
10258          <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>
10259          <div id="mc-ic-c1"></div>
10260        </div>
10261        <!-- Language Code Delta -->
10262        <div class="ic-card" id="mc-ic-lang-card">
10263          <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>
10264          <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>
10265          <div id="mc-ic-c3"></div>
10266        </div>
10267        <!-- Delta by Metric -->
10268        <div class="ic-card">
10269          <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>
10270          <div id="mc-ic-c2"></div>
10271        </div>
10272        <!-- File Change Distribution -->
10273        <div class="ic-card">
10274          <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>
10275          <div id="mc-ic-c4"></div>
10276        </div>
10277      </div>
10278    </div>
10279
10280    <!-- File matrix table -->
10281    <div class="panel">
10282      <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>
10283      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10284        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10285          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10286          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10287          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10288          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10289          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10290        </div>
10291        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10292          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10293          <div class="export-group">
10294          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10295          <button type="button" class="export-btn" id="export-csv-btn">
10296            <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>
10297            CSV
10298          </button>
10299          <button type="button" class="export-btn" id="mc-file-xls-btn">
10300            <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>
10301            Excel
10302          </button>
10303          </div>
10304        </div>
10305      </div>
10306      <div class="table-wrap">
10307        <table id="file-table">
10308          <thead>
10309            <tr>
10310              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10311              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10312              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10313              {file_col_headers}
10314              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10315            </tr>
10316          </thead>
10317          <tbody id="file-tbody"></tbody>
10318        </table>
10319      </div>
10320      <div class="pagination">
10321        <span class="pagination-info" id="pg-info"></span>
10322        <div class="pagination-btns" id="pg-btns"></div>
10323        <div style="display:flex;align-items:center;gap:6px;">
10324          <span style="font-size:12px;color:var(--muted)">Show</span>
10325          <select class="per-page" id="per-page-sel">
10326            <option value="25" selected>25 per page</option>
10327            <option value="50">50 per page</option>
10328            <option value="100">100 per page</option>
10329          </select>
10330        </div>
10331      </div>
10332    </div>
10333  </div>
10334
10335  <div id="mc-ic-tt"></div>
10336
10337  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10338    <div class="ic-svg-modal">
10339      <div class="ic-svg-modal-hdr">
10340        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10341        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10342      </div>
10343      <div id="ic-svg-modal-body"></div>
10344    </div>
10345  </div>
10346
10347  <footer class="site-footer">
10348    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10349    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10350    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10351    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10352    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10353  </footer>
10354
10355  <script nonce="{csp_nonce}">
10356  (function(){{
10357    // ── Dark theme ───────────────────────────────────────────────────────────
10358    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10359    var renderInlineCharts=null;
10360    var tt=document.getElementById('theme-toggle');
10361    if(tt)tt.addEventListener('click',function(){{
10362      var on=document.body.classList.toggle('dark-theme');
10363      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10364      renderChart(activeMetric);
10365      if(renderInlineCharts)renderInlineCharts();
10366    }});
10367
10368    // ── Code particles ───────────────────────────────────────────────────────
10369    var container=document.getElementById('code-particles');
10370    if(container){{
10371      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()'];
10372      for(var i=0;i<28;i++){{
10373        (function(idx){{
10374          var el=document.createElement('span');el.className='code-particle';
10375          el.textContent=snips[idx%snips.length];
10376          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10377          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10378          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10379          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10380          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10381          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10382          container.appendChild(el);
10383        }})(i);
10384      }}
10385    }}
10386
10387    // ── Watermarks ───────────────────────────────────────────────────────────
10388    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10389    if(wms.length){{
10390      var placed=[];
10391      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;}}
10392      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];}}
10393      var half=Math.floor(wms.length/2);
10394      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;}});
10395    }}
10396
10397    // ── Settings / colour scheme modal ───────────────────────────────────────
10398    (function(){{
10399      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'}}];
10400      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);}});}}
10401      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10402      function init(){{
10403        var btn=document.getElementById('settings-btn');if(!btn)return;
10404        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10405        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>';
10406        document.body.appendChild(m);
10407        var g=document.getElementById('scheme-grid');
10408        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);}});
10409        var cl=document.getElementById('settings-close-btn');
10410        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');}});
10411        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10412        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10413      }}
10414      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10415    }})();
10416
10417    // ── Timezone support for scan timestamps ─────────────────────────────────
10418    (function(){{
10419      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);}};
10420      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'';}}}};
10421      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);}});}};
10422      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10423      window.applyTz(storedTz);
10424      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);}});}}}}
10425      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10426    }})();
10427
10428    // ── Data ────────────────────────────────────────────────────────────────
10429    var POINTS={points_json};
10430    var FILES={file_matrix_json};
10431    var N={n};
10432
10433    // ── fmt helper ───────────────────────────────────────────────────────────
10434    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();}}
10435    function fmtFull(n){{return Number(n).toLocaleString();}}
10436    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10437
10438    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10439    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10440    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));}}
10441    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10442    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10443
10444    // ── Timeline chart ───────────────────────────────────────────────────────
10445    var activeMetric='code';
10446    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10447    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10448
10449    function renderChart(metric){{
10450      var svg=document.getElementById('mc-chart');if(!svg)return;
10451      var W=svg.getBoundingClientRect().width||800,H=280;
10452      svg.setAttribute('height',H);
10453      var pad={{l:62,r:20,t:32,b:72}};
10454      var dark=document.body.classList.contains('dark-theme');
10455      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10456      var valid=pts.filter(function(v){{return v!=null;}});
10457      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;}}
10458      var minV=0,maxV=Math.max.apply(null,valid);
10459      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10460      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10461      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10462      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10463      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10464      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10465      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10466      var parts=[];
10467      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10468      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>');}}
10469      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10470      var lineD='';var firstPt=true;
10471      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);}}}}
10472      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10473      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10474      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10475      for(var i=0;i<N;i++){{
10476        if(pts[i]==null)continue;
10477        var cx=xOf(i),cy=yOf(pts[i]);
10478        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10479        var hasTag=p.tags&&p.tags.length>0;
10480        // Permanent Y-value label above the dot
10481        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>');
10482        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+'"/>');
10483        var xanchor=i===0?'start':i===N-1?'end':'middle';
10484        // X-axis label at 2× the original size (18 px)
10485        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>');
10486      }}
10487      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10488      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10489      svg.innerHTML=parts.join('');
10490      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');}});
10491      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10492      svg.onmousemove=function(e){{
10493        var rect=svg.getBoundingClientRect();
10494        var scaleX=W/rect.width;
10495        var mouseX=(e.clientX-rect.left)*scaleX;
10496        var nearest=-1,minDist=Infinity;
10497        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;}}}}
10498        if(nearest<0)return;
10499        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10500        var xhair=svg.querySelector('.mc-xhair');
10501        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10502        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"/>';
10503        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10504        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10505        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>';
10506        var bx=rect.left+(nc/W*rect.width)+18;
10507        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10508        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10509      }};
10510      svg.onmouseleave=function(){{
10511        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10512        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10513      }};
10514    }}
10515
10516    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10517
10518    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10519      btn.addEventListener('click',function(){{
10520        activeMetric=this.dataset.metric;
10521        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10522        this.classList.add('active');
10523        renderChart(activeMetric);
10524      }});
10525    }});
10526    if(typeof ResizeObserver!=='undefined'){{
10527      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10528    }}
10529    renderChart(activeMetric);
10530
10531    // ── File matrix table ────────────────────────────────────────────────────
10532    var activeStatus='';
10533    var currentPage=1;
10534    var perPage=25;
10535    var mcSortCol=null,mcSortAsc=true;
10536
10537    function getFiltered(){{
10538      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10539      if(!mcSortCol)return data;
10540      var asc=mcSortAsc;
10541      return data.slice().sort(function(a,b){{
10542        var va,vb;
10543        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10544        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10545        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10546        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10547        else{{return 0;}}
10548        if(asc)return va<vb?-1:va>vb?1:0;
10549        return va<vb?1:va>vb?-1:0;
10550      }});
10551    }}
10552
10553    function renderFilePage(){{
10554      var filtered=getFiltered();
10555      var total=filtered.length;
10556      var totalPages=Math.max(1,Math.ceil(total/perPage));
10557      if(currentPage>totalPages)currentPage=totalPages;
10558      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10559      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10560      var rows=[];
10561      for(var i=start;i<end;i++){{
10562        var f=filtered[i];
10563        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10564        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10565        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10566        for(var j=0;j<N;j++){{
10567          var cv=f.c[j];
10568          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10569          if(j<N-1){{
10570            var dv=f.d[j+1];
10571            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10572              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10573          }}
10574        }}
10575        var tc=f.t;
10576        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10577        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10578      }}
10579      tbody.innerHTML=rows.join('');
10580
10581      var info=document.getElementById('pg-info');
10582      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10583      renderPgBtns(totalPages);
10584    }}
10585
10586    function renderPgBtns(totalPages){{
10587      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10588      var btns=[];
10589      function mkBtn(label,page,active,disabled){{
10590        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10591        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10592      }}
10593      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10594      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10595      if(s>1)btns.push(mkBtn('1',1,false,false));
10596      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10597      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10598      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10599      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10600      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10601      wrap.innerHTML=btns.join('');
10602      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10603        b.addEventListener('click',function(){{
10604          var pg=parseInt(this.dataset.pg,10);
10605          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10606        }});
10607      }});
10608    }}
10609
10610    // Tab filter
10611    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10612      btn.addEventListener('click',function(){{
10613        activeStatus=this.dataset.status||'';
10614        currentPage=1;
10615        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10616        this.classList.add('active');
10617        renderFilePage();
10618      }});
10619    }});
10620
10621    // Per-page selector
10622    var ppSel=document.getElementById('per-page-sel');
10623    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10624
10625    // ── Column header sort ───────────────────────────────────────────────────
10626    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10627      th.addEventListener('click',function(){{
10628        var col=th.dataset.sortCol;
10629        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10630        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10631          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10632        }});
10633        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10634        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10635        currentPage=1;renderFilePage();
10636      }});
10637    }});
10638
10639    // Reset button also clears sort
10640    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10641    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10642      mcSortCol=null;mcSortAsc=true;
10643      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10644        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10645      }});
10646      activeStatus='';currentPage=1;
10647      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10648      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10649      renderFilePage();
10650    }});
10651
10652    renderFilePage();
10653
10654    // ── CSV export ───────────────────────────────────────────────────────────
10655    var exportBtn=document.getElementById('export-csv-btn');
10656    if(exportBtn)exportBtn.addEventListener('click',function(){{
10657      var header=['File','Language','Status'];
10658      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10659      header.push('Net Delta');
10660      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10661      var filtered=getFiltered();
10662      filtered.forEach(function(f){{
10663        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10664        for(var j=0;j<N;j++){{
10665          cols.push(f.c[j]!=null?f.c[j]:'');
10666          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10667        }}
10668        cols.push(f.t);
10669        rows.push(cols.join(','));
10670      }});
10671      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10672      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10673      a.download=mcExportName('csv');a.click();
10674    }});
10675
10676    // ── File matrix extra export buttons ─────────────────────────────────────
10677    (function(){{
10678      var resetBtn=document.getElementById('mc-file-reset-btn');
10679      if(resetBtn)resetBtn.addEventListener('click',function(){{
10680        activeStatus='';currentPage=1;
10681        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10682        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10683        renderFilePage();
10684      }});
10685
10686      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10687      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10688      function mcMakeXlsx(fname){{
10689        var filtered=getFiltered();
10690        var enc=new TextEncoder();
10691        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;}}
10692        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;}}
10693        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10694        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10695        var ss=[],si={{}};
10696        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10697        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10698        function WS(){{
10699          var R=0,buf=[];
10700          function cl(c){{return String.fromCharCode(65+c);}}
10701          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10702          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10703          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10704          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>';}}
10705          return{{sc:sc,nc:nc,row:row,xml:xml}};
10706        }}
10707        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10708        var proj=mcExportProj();
10709        // \u2500\u2500 Summary sheet \u2500\u2500
10710        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10711        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10712        r1(s1(0,proj,2));
10713        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10714        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
10715        r1('');
10716        r1(s1(0,'SCAN SUMMARY',8));
10717        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));
10718        POINTS.forEach(function(p,i){{
10719          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10720          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));
10721        }});
10722        r1('');
10723        if(POINTS.length>1){{
10724          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10725          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10726          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10727          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)));}};
10728          nr('Code Lines',pf.code,pl.code);
10729          nr('Comment Lines',pf.comments,pl.comments);
10730          nr('Files Analyzed',pf.files,pl.files);
10731          nr('Tests',pf.tests,pl.tests);
10732          r1('');
10733        }}
10734        var cMod=0,cAdd=0,cRem=0,cUnch=0;
10735        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
10736        var totF=FILES.length||1;
10737        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
10738        r1(s1(0,'FILE CHANGES',8));
10739        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
10740        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
10741        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
10742        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
10743        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
10744        var lm={{}};
10745        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;}});
10746        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
10747        if(langs.length){{
10748          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
10749          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
10750          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)));}});
10751        }}
10752        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
10753        // \u2500\u2500 File Delta sheet \u2500\u2500
10754        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
10755        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
10756        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);}}
10757        hcells+=s2(hc,'Net Delta',3);
10758        r2(hcells);
10759        filtered.forEach(function(f){{
10760          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
10761          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));}}}}
10762          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
10763          r2(cells);
10764        }});
10765        var ncols=3+N+(N-1)+1;
10766        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
10767        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>';
10768        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
10769        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>',
10770          '_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>',
10771          '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>',
10772          '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>',
10773          '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>',
10774          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
10775        var zparts=[],zcds=[],zoff=0,znf=0;
10776        ['[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){{
10777          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
10778          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]);
10779          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);
10780          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));
10781          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
10782          zoff+=entry.length;znf++;
10783        }});
10784        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
10785        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]);
10786        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
10787        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10788        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
10789        out.set(new Uint8Array(eocd),pos);
10790        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
10791        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10792      }}
10793
10794      var xlsBtn=document.getElementById('mc-file-xls-btn');
10795      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
10796
10797      // File matrix HTML export — interactive: sort by column, filter by status
10798      function mcFileBuildHtml(){{
10799        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10800        var hdrs=['File','Language','Status'];
10801        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
10802        hdrs.push('Net \u0394');
10803        var SI=2;
10804        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;}});
10805        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
10806        var cnt={{all:allRows.length}};
10807        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
10808        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
10809        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
10810          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
10811          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
10812          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
10813          '.sub{{font-size:12px;color:#99aabb;}}'+
10814          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
10815          '.wr{{padding:16px 20px;}}'+
10816          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
10817          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
10818          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
10819          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
10820          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
10821          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
10822          'thead tr{{background:#1a2035;}}'+
10823          '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;}}'+
10824          'th:hover{{background:#2a3050;}}'+
10825          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
10826          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
10827          'tr:nth-child(even) td{{background:#faf7f4;}}'+
10828          'tr:hover td{{background:#f5f0ea;}}'+
10829          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
10830          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
10831        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
10832        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
10833          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
10834          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
10835          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
10836          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
10837        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
10838          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
10839          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
10840          '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>";}}'+
10841          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
10842          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
10843          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
10844          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
10845          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
10846          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
10847          '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("")'+
10848          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
10849          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
10850          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
10851          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
10852          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
10853          'sd=(sc===ci)?-sd:1;sc=ci;'+
10854          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
10855          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
10856          'render();';
10857        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
10858          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
10859          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
10860          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
10861          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
10862          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
10863          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
10864          '<script>'+inlineJs+'<\/script><\/body><\/html>';
10865      }}
10866
10867      var htmlBtn=document.getElementById('mc-file-html-btn');
10868      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
10869        var h=mcFileBuildHtml();
10870        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
10871        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10872        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
10873      }});
10874
10875      var pdfBtn=document.getElementById('mc-file-pdf-btn');
10876      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
10877        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
10878      }});
10879    }})();
10880
10881    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
10882    (function(){{
10883      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
10884      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
10885      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
10886      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10887      function fmt2(n){{return Number(n).toLocaleString();}}
10888      function px(n){{return Math.round(n);}}
10889      var _tt=document.getElementById('mc-ic-tt');
10890      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
10891      function addTT(el){{
10892        if(!el)return;
10893        el.addEventListener('mouseover',function(e){{
10894          var t=e.target.closest('[data-ttl]');
10895          if(t&&_tt){{
10896            var ttl=t.getAttribute('data-ttl');
10897            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
10898            _tt.style.display='block';mvTT(e);
10899            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10900            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
10901          }} else {{
10902            if(_tt)_tt.style.display='none';
10903            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10904          }}
10905        }});
10906        el.addEventListener('mouseleave',function(){{
10907          if(_tt)_tt.style.display='none';
10908          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
10909        }});
10910        el.addEventListener('mousemove',function(e){{mvTT(e);}});
10911      }}
10912      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';}}
10913      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
10914      function buildCharts(){{
10915        if(N<2)return;
10916        var cs=getComputedStyle(document.body);
10917        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
10918        var textCol=cv('--text','#43342d');
10919        var mutedCol=cv('--muted','#7b675b');
10920        var gFill=cv('--muted-2','#a08777');
10921        var LGY=cv('--line','#e6d0bf');
10922        var axisCol=cv('--line-strong','#d8bfad');
10923        var surf2col=cv('--surface-2','#f4ede4');
10924        var surfCol=cv('--surface','#fff8f0');
10925        var p0=POINTS[0],pLast=POINTS[N-1];
10926        var dark=document.body.classList.contains('dark-theme');
10927        var FADE=dark?'#524238':'#e6d0bf';
10928        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
10929        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;}}
10930      var c1mets=[
10931        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
10932        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
10933        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
10934      ];
10935      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
10936      // Code Metrics chart — grows to fill the height its grid row settled to (the
10937      // Language Code Delta sibling usually drives that), so it never sits short at
10938      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
10939      function drawC1(){{
10940        var C1W=620,C1H=200;
10941        var c1host=document.getElementById('mc-ic-c1');
10942        var c1card=c1host?c1host.closest('.ic-card'):null;
10943        if(c1host&&c1card&&c1host.clientWidth>0){{
10944          var avW=c1host.clientWidth;
10945          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
10946          var wantH=availPx*C1W/avW;
10947          if(wantH>C1H)C1H=wantH;
10948        }}
10949        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
10950        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10951        for(var gi=1;gi<=4;gi++){{
10952          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
10953          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
10954          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
10955        }}
10956        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
10957        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
10958        c1mets.forEach(function(m,i){{
10959          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
10960          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
10961          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
10962          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;"/>';
10963          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>';
10964          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;"/>';
10965          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>';
10966          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>';
10967          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>';
10968        }});
10969        c1+='</svg>';
10970        return c1;
10971      }}
10972      // Chart 2: Delta by Metric (net delta first scan to last)
10973      var mets=[
10974        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
10975        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
10976        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
10977      ];
10978      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
10979      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;
10980      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
10981      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
10982      mets.forEach(function(m,i){{
10983        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);
10984        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>';
10985        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;"/>';
10986        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>';}}
10987        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>';}}
10988      }});
10989      c2+='</svg>';
10990      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
10991      var lm={{}};
10992      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;}});
10993      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
10994      function drawC3(){{
10995        if(!langs.length)return'';
10996        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
10997        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;
10998        var c3host=document.getElementById('mc-ic-c3');
10999        var c3card=document.getElementById('mc-ic-lang-card');
11000        var C3H=langs.length*30+24;
11001        if(c3host&&c3card&&c3host.clientWidth>0){{
11002          var avW=c3host.clientWidth;
11003          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11004          var wantH=availPx*C3W/avW;
11005          if(wantH>C3H)C3H=wantH;
11006        }}
11007        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11008        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11009        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11010        langs.forEach(function(l,i){{
11011          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);
11012          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11013          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"/>';
11014          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>';}}
11015          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>';}}
11016          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>';
11017        }});
11018        c3+='</svg>';
11019        return c3;
11020      }}
11021      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11022      var fm=0,fa=0,fr=0,fu=0;
11023      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11024      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;}});
11025      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11026      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11027      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11028      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;
11029      if(segs.length===1){{
11030        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"/>';
11031        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11032        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>';
11033      }} else {{
11034        segs.forEach(function(s){{
11035          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11036          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);
11037          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);
11038          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"/>';
11039          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>';}}
11040          ang4+=sw;
11041        }});
11042      }}
11043      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11044      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11045      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11046      segs.forEach(function(s,i){{
11047        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11048        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;"/>';
11049        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>';
11050        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11051      }});
11052      c4+='</svg>';
11053      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11054      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11055      // once at natural height to seed the row, then both are filled to the row the
11056      // grid settled to, so neither sits short at the top of an over-tall cell.
11057      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11058      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11059      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11060      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11061      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>';
11062      if(e1)e1.innerHTML=drawC1();
11063      }}
11064      buildCharts();
11065      renderInlineCharts=buildCharts;
11066      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11067      (function(){{
11068        var ov=document.getElementById('ic-svg-modal-ov');
11069        var body=document.getElementById('ic-svg-modal-body');
11070        var ttl=document.getElementById('ic-svg-modal-title');
11071        var closeBtn=document.getElementById('ic-svg-modal-close');
11072        if(!ov||!body)return;
11073        function close(){{ov.classList.remove('open');body.innerHTML='';}}
11074        function open(srcId,title){{
11075          var src=document.getElementById(srcId);if(!src)return;
11076          ttl.textContent=title||'';
11077          var card=src.closest('.ic-card');
11078          var legHtml='';
11079          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11080          body.innerHTML=legHtml+src.innerHTML;
11081          var svg=body.querySelector('svg');
11082          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11083          addTT(body);
11084          ov.classList.add('open');
11085        }}
11086        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11087          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11088        }});
11089        if(closeBtn)closeBtn.addEventListener('click',close);
11090        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11091        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11092      }})();
11093
11094      // HTML legend hover → highlight matching SVG bars within the SAME card only
11095      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11096        var metric=leg.getAttribute('data-highlight');
11097        var parentCard=leg.closest('.ic-card');
11098        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11099        if(!chartEl)return;
11100        leg.addEventListener('mouseenter',function(){{
11101          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11102            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11103              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11104              x.style.opacity='1';
11105            }} else {{
11106              x.style.opacity='0.28';
11107            }}
11108          }});
11109        }});
11110        leg.addEventListener('mouseleave',function(){{
11111          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11112        }});
11113      }});
11114      // Author handles
11115      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11116
11117      // ── Export helpers ────────────────────────────────────────────────────────
11118      // Fetch one image from the server and return a data-URI Promise
11119      function mcFetchUri(path){{
11120        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11121          return new Promise(function(res){{
11122            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11123          }});
11124        }}).catch(function(){{return '';}});
11125      }}
11126      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11127      function mcInlineImgs(html,cb){{
11128        var paths=[],seen={{}};
11129        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11130        if(!paths.length){{cb(html);return;}}
11131        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11132          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11133          .catch(function(){{cb(html);}});
11134      }}
11135      // Capture full-page HTML with all table rows visible
11136      function mcRawHtml(pdfMode){{
11137        if(pdfMode)document.body.classList.add('pdf-mode');
11138        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11139        var html=document.documentElement.outerHTML;
11140        perPage=s;currentPage=p;renderFilePage();
11141        if(pdfMode)document.body.classList.remove('pdf-mode');
11142        return html;
11143      }}
11144
11145      // HTML export (full page with inlined images)
11146      function mcDoHtml(btn,fname){{
11147        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11148        mcInlineImgs(mcRawHtml(false),function(html){{
11149          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11150          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11151          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11152          btn.disabled=false;btn.innerHTML=orig;
11153        }});
11154      }}
11155      // PDF export — comprehensive document-style report: full numbers, all sections
11156      function mcBuildPdfHtml(){{
11157        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11158        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11159        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11160        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>';}}
11161        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11162        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11163        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)));}}
11164        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11165        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11166        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11167        // Header/footer flow in document order (NOT position:fixed) — a fixed
11168        // header repeats every printed page in Chromium and overlaps the content
11169        // below it, swallowing the first rows of pages 2+ and clipping the cards
11170        // on page 1. The table <thead> repeats per page natively, so every row
11171        // stays visible.
11172        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11173          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11174          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11175          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11176          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11177          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11178          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11179          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11180          '.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;}}'+
11181          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11182          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11183          '.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;}}'+
11184          '.body{{padding:12px 18px 0;}}'+
11185          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11186          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11187          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11188          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11189          '.sec{{margin-bottom:10px;}}'+
11190          '.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;}}'+
11191          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11192          '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;}}'+
11193          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11194          'tr:nth-child(even) td{{background:#faf8f6;}}';
11195        // ── Metric Progression ────────────────────────────────────────────────
11196        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11197        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11198        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>';
11199        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11200        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11201        var progRows=POINTS.map(function(pt,i){{
11202          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)));
11203          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11204            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11205            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11206            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11207            '<td style="text-align:right">'+full(pt.files)+'</td>';
11208          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11209          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11210          return r+'</tr>';
11211        }}).join('');
11212        // ── Scan-to-scan changes ──────────────────────────────────────────────
11213        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11214          var prev=POINTS[i];
11215          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11216          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11217          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11218            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11219            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11220            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11221            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11222        }}).join(''):'';
11223        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11224        var fmSection='';
11225        if(FILES&&FILES.length){{
11226          // Hard cap on per-scan columns so the table never overflows the page width.
11227          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11228          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11229          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11230          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11231          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11232          var fmRows=topFiles.map(function(f){{
11233            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11234            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>';
11235            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11236            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11237            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>';
11238          }}).join('');
11239          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11240          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11241            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11242        }}
11243        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11244          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11245          '<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>'+
11246
11247          '<div class="body">'+
11248          '<div class="sg">'+
11249          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11250            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11251          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11252            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11253          (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>':
11254            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11255          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11256          '</div>'+
11257          '<div class="sec"><p class="sh">Metric Progression</p>'+
11258          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11259          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11260          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11261          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11262          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11263          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11264          fmSection+
11265          '</div>'+
11266          '<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>'+
11267          '</body></html>';
11268      }}
11269      function mcDoPdf(btn){{
11270        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11271      }}
11272
11273      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11274      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11275      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11276      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11277      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11278      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11279      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11280      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11281      if(location.protocol==='file:'){{
11282        [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';}}}} );
11283        [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';}}}} );
11284      }}
11285    }})();
11286    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11287    (function(){{
11288      function $(id){{return document.getElementById(id);}}
11289      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11290      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11291      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11292      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11293      function openModal(idx){{
11294        var ov=$('mc-modal-overlay');if(!ov)return;
11295        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11296        if(idx<0||idx>=N)return;
11297        var pt=POINTS[idx];
11298        titleEl.textContent='Scan '+(idx+1);
11299        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11300        subEl.textContent=lbl;
11301        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11302          '<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>'+
11303          '<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>'+
11304          '<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>'+
11305          '<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>'+
11306          (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>':'')+
11307          (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>':'')+
11308          '</div></div>';
11309        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11310          (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>':'')+
11311          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11312          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11313          (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>':'')+
11314          (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>':'')+
11315          (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>':'')+
11316          (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>':'')+
11317          '<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>'+
11318          '</div>';
11319        var dHtml='';
11320        if(idx>0){{
11321          var prev=POINTS[idx-1];
11322          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11323          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11324            '<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>'+
11325            '<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>'+
11326            '<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>'+
11327            '</div></div>';
11328        }}
11329        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11330        ov.classList.add('open');document.body.style.overflow='hidden';
11331      }}
11332      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11333      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11334      document.addEventListener('click',function(e){{
11335        if(!e.target||!e.target.closest)return;
11336        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11337        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11338        var card=e.target.closest('.mc-card');
11339        if(!card)return;
11340        if(e.target.closest('a'))return;
11341        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11342        var i=cards.indexOf(card);
11343        if(i>=0)openModal(i);
11344      }});
11345      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11346      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11347      var statTip=null;
11348      document.addEventListener('mousemove',function(e){{
11349        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11350        if(!box){{if(statTip)statTip.style.display='none';return;}}
11351        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11352        var tip=box.getAttribute('data-tip')||'';
11353        if(statTip.textContent!==tip)statTip.textContent=tip;
11354        statTip.style.display='block';
11355        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11356        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11357        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11358        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11359      }});
11360      (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');}})();
11361    }})();
11362  }})();
11363  </script>
11364  <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]';
11365  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;}}
11366  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>
11367  <!-- Scan card detail modal -->
11368  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11369    <div class="mc-modal" id="mc-modal">
11370      <div class="mc-modal-head">
11371        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11372        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11373      </div>
11374      <div class="mc-modal-body" id="mc-modal-body"></div>
11375    </div>
11376  </div>
11377  {toast_assets}
11378</body>
11379</html>"#,
11380        project_label = html_escape(project_label),
11381        n = n,
11382        scan_strip = scan_strip,
11383        mc_strip_class = mc_strip_class,
11384        metrics_thead = metrics_thead,
11385        metrics_tbody = metrics_tbody,
11386        file_col_headers = file_col_headers,
11387        total_files = total_files,
11388        files_modified = files_modified,
11389        files_added = files_added,
11390        files_removed = files_removed,
11391        files_unchanged = files_unchanged,
11392        points_json = points_json,
11393        file_matrix_json = file_matrix_json,
11394        nav_compare_active = nav_compare_active,
11395        version = version,
11396        csp_nonce = csp_nonce,
11397        scope_bar_html = scope_bar_html,
11398        scope_label = scope_label,
11399        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11400    )
11401}
11402
11403// ── Trend report page ─────────────────────────────────────────────────────────
11404// Protected. Interactive time-series chart page that loads scan history via
11405// /api/metrics/history and renders a vanilla-SVG line chart.
11406//
11407// GET /trend-reports
11408
11409#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11410async fn trend_report_handler(
11411    State(state): State<AppState>,
11412    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11413) -> Response {
11414    auto_scan_watched_dirs(&state).await;
11415
11416    let watched_dirs_list: Vec<String> = {
11417        let wd = state.watched_dirs.lock().await;
11418        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11419    };
11420
11421    // Collect distinct project roots for the root selector dropdown.
11422    let roots: Vec<String> = {
11423        let reg = state.registry.lock().await;
11424        let mut seen = std::collections::BTreeSet::new();
11425        reg.entries
11426            .iter()
11427            .flat_map(|e| e.input_roots.iter().cloned())
11428            .filter(|r| seen.insert(r.clone()))
11429            .collect()
11430    };
11431
11432    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11433    let nonce = &csp_nonce;
11434    let version = env!("CARGO_PKG_VERSION");
11435    let toast_assets = sloc_toast_assets(nonce);
11436
11437    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11438    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11439    // of interactive controls — folder watching is managed by the host administrator.
11440    let watched_dirs_html: String = if state.server_mode {
11441        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()
11442    } else {
11443        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11444            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11445                .to_string()
11446        } else {
11447            watched_dirs_list
11448                .iter()
11449                .fold(String::new(), |mut s, d| {
11450                    use std::fmt::Write as _;
11451                    let escaped =
11452                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11453                    write!(
11454                        s,
11455                        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>"#
11456                    ).expect("write to String is infallible");
11457                    s
11458                })
11459        };
11460        format!(
11461            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>"#
11462        )
11463    };
11464
11465    let html = format!(
11466        r##"<!doctype html>
11467<html lang="en">
11468<head>
11469  <meta charset="utf-8" />
11470  <meta name="viewport" content="width=device-width, initial-scale=1" />
11471  <title>OxideSLOC | Trend Reports</title>
11472  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11473  <style nonce="{nonce}">
11474    :root {{
11475      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11476      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11477      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11478      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11479      --info-bg:#eef3ff; --info-text:#4467d8;
11480    }}
11481    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11482    *{{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;}}
11483    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11484    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11485    .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;}}
11486    @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));}}}}
11487    .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);}}
11488    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11489    .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));}}
11490    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11491    .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;}}
11492    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11493    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11494    @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; }} }}
11495    .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;}}
11496    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11497    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11498    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11499    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11500    .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;}}
11501    .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;}}
11502    .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;}}
11503    .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;}}
11504    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11505    .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);}}
11506    .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;}}
11507    .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;}}
11508    .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;}}
11509    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11510    .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;}}
11511    .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);}}
11512    .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;}}
11513    .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;}}
11514    .tz-select:focus{{border-color:var(--oxide);}}
11515    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11516    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11517    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11518    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11519    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11520    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11521    .trend-title-block{{flex:1;min-width:0;}}
11522    .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;}}
11523    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11524    .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;}}
11525    .chart-select:focus{{border-color:var(--accent);}}
11526    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11527    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11528    .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);}}
11529    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11530    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11531    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11532    .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);}}
11533    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11534    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11535    .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;}}
11536    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11537    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11538    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11539    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11540    .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;}}
11541    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11542    .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;}}
11543    .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);}}
11544    .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;}}
11545    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11546    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11547    .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);}}
11548    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11549    .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;}}
11550    .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;}}
11551    .data-table tr:last-child td{{border-bottom:none;}}
11552    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11553    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11554    .table-wrap{{width:100%;overflow-x:auto;}}
11555    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11556    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11557    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11558    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11559    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11560    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11561    .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;}}
11562    .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;}}
11563    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11564    .pagination-info{{font-size:13px;color:var(--muted);}}
11565    .pagination-btns{{display:flex;gap:6px;}}
11566    .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;}}
11567    .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;}}
11568    #scan-history-table col:nth-child(1){{width:155px;}}
11569    #scan-history-table col:nth-child(2){{width:240px;}}
11570    #scan-history-table col:nth-child(3){{width:82px;}}
11571    #scan-history-table col:nth-child(4){{width:82px;}}
11572    #scan-history-table col:nth-child(5){{width:90px;}}
11573    #scan-history-table col:nth-child(6){{width:90px;}}
11574    #scan-history-table col:nth-child(7){{width:88px;}}
11575    #scan-history-table col:nth-child(8){{width:150px;}}
11576    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11577    .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;}}
11578    .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;}}
11579    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11580    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11581    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11582    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11583    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11584    .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;}}
11585    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11586    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11587    .watched-chip-rm:hover{{color:var(--oxide);}}
11588    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11589    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11590    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11591    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11592    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11593    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11594    a.run-link:hover{{text-decoration:underline;}}
11595    .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);}}
11596    .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);}}
11597    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11598    .metric-num{{font-weight:700;color:var(--text);}}
11599    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11600    .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;}}
11601    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11602    .btn.primary:hover{{opacity:.9;}}
11603    .rpt-btn{{min-width:58px;justify-content:center;}}
11604    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11605    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11606    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11607    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11608    .submod-details summary::-webkit-details-marker{{display:none;}}
11609    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11610    .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;}}
11611    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11612    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11613    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11614    .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;}}
11615    .export-btn:hover{{background:var(--line);}}
11616    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11617    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11618    .site-footer a{{color:var(--muted);}}
11619    .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;}}
11620    .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;}}
11621    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11622    /* Modal system (Retention Policy / Clean-up) */
11623    .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;}}
11624    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11625    .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);}}
11626    .tr-modal{{background:rgba(255,255,255,0.90);}}
11627    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11628    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11629    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11630    .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);}}
11631    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11632    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11633    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11634    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11635    .tr-modal-body{{padding:22px 30px;}}
11636    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11637    .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;}}
11638    .tr-btn:hover{{transform:translateY(-1px);}}
11639    .tr-btn:active{{transform:translateY(0);}}
11640    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11641    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11642    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11643    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11644    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11645    .tr-btn-secondary:hover{{background:var(--line);}}
11646    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11647    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11648  </style>
11649</head>
11650<body>
11651  <div class="background-watermarks" aria-hidden="true">
11652    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11653    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11654    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11655    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11656    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11657    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11658  </div>
11659  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11660  <div class="top-nav">
11661    <div class="top-nav-inner">
11662      <a class="brand" href="/">
11663        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11664        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11665      </a>
11666      <div class="nav-right">
11667        <a class="nav-pill" href="/">Home</a>
11668        <div class="nav-dropdown">
11669          <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>
11670          <div class="nav-dropdown-menu">
11671            <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>
11672          </div>
11673        </div>
11674        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11675        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11676        <div class="nav-dropdown">
11677          <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>
11678          <div class="nav-dropdown-menu">
11679            <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>
11680          </div>
11681        </div>
11682        <div class="server-status-wrap" id="server-status-wrap">
11683          <div class="nav-pill server-online-pill" id="server-status-pill">
11684            <span class="status-dot" id="status-dot"></span>
11685            <span id="server-status-label">Server</span>
11686            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11687          </div>
11688          <div class="server-status-tip">
11689            OxideSLOC is running — accessible on your network.
11690            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11691          </div>
11692        </div>
11693        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11694          <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>
11695        </button>
11696        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11697          <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>
11698          <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>
11699        </button>
11700      </div>
11701    </div>
11702  </div>
11703
11704  <div class="page">
11705    {watched_dirs_html}
11706    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
11707      <div class="scan-overlay-card">
11708        <div class="scan-spinner"></div>
11709        <div class="scan-overlay-text">Scanning folder…</div>
11710        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
11711      </div>
11712    </div>
11713    <style>
11714    .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);}}
11715    .scan-overlay.active{{display:flex;}}
11716    .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;}}
11717    .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;}}
11718    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
11719    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
11720    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
11721    </style>
11722    <div class="summary-strip" id="trend-stats"></div>
11723    <div class="panel">
11724      <div class="trend-header">
11725        <div class="trend-title-block">
11726          <h1>Trend Reports</h1>
11727          <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>
11728          <span class="chart-hint-inline">
11729            <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>
11730            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
11731          </span>
11732        </div>
11733        <div class="chart-actions">
11734          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11735            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
11736            Retention Policy
11737          </button>
11738          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
11739            <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>
11740            Clean up old runs
11741          </button>
11742          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
11743            <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>
11744            Export Excel
11745          </button>
11746          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
11747            <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>
11748            Export PNG
11749          </button>
11750          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
11751            <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>
11752            Export PDF
11753          </button>
11754        </div>
11755      </div>
11756
11757      <div class="controls-centered">
11758        <label>Project Root:
11759          <select class="chart-select" id="root-sel">
11760            <option value="">All projects</option>
11761          </select>
11762        </label>
11763        <label>Y Metric:
11764          <select class="chart-select" id="y-sel">
11765            <option value="code_lines">Code Lines</option>
11766            <option value="comment_lines">Comment Lines</option>
11767            <option value="blank_lines">Blank Lines</option>
11768            <option value="physical_lines">Physical Lines</option>
11769            <option value="files_analyzed">Files Analyzed</option>
11770          </select>
11771        </label>
11772        <label>X Axis:
11773          <select class="chart-select" id="x-sel">
11774            <option value="time">By Time</option>
11775            <option value="commit" selected>By Commit</option>
11776            <option value="release">By Release</option>
11777            <option value="tag">Tagged Commits</option>
11778          </select>
11779        </label>
11780        <label id="submodule-label" style="display:none;">Submodule:
11781          <select class="chart-select" id="sub-sel">
11782            <option value="">All (project total)</option>
11783          </select>
11784        </label>
11785        <label>Chart Size:
11786          <select class="chart-select" id="scale-sel">
11787            <option value="0.75">Compact</option>
11788            <option value="1.2" selected>Normal</option>
11789            <option value="1.38">Large</option>
11790          </select>
11791        </label>
11792        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
11793      </div>
11794
11795      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
11796      <div id="data-table-wrap" style="overflow-x:auto;"></div>
11797    </div>
11798  </div>
11799
11800  <script nonce="{nonce}">
11801    (function() {{
11802      // Theme persistence
11803      var b = document.body;
11804      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
11805      var tgl = document.getElementById('theme-toggle');
11806      if (tgl) tgl.addEventListener('click', function() {{
11807        var d = b.classList.toggle('dark-theme');
11808        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
11809      }});
11810
11811      // Watermark randomizer
11812      (function() {{
11813        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
11814        if (!wms.length) return;
11815        var placed = [];
11816        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;}}
11817        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];}}
11818        var half=Math.floor(wms.length/2);
11819        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;}});
11820      }})();
11821
11822      // Code particles
11823      (function() {{
11824        var container = document.getElementById('code-particles');
11825        if (!container) return;
11826        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'];
11827        for (var i = 0; i < 38; i++) {{
11828          (function(idx) {{
11829            var el = document.createElement('span');
11830            el.className = 'code-particle';
11831            el.textContent = snippets[idx % snippets.length];
11832            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
11833            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
11834            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
11835            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';
11836            container.appendChild(el);
11837          }})(i);
11838        }}
11839      }})();
11840
11841      // Watched folder picker
11842      (function(){{
11843        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');}};
11844        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);
11845      }})();
11846      (function() {{
11847        var btn = document.getElementById('add-watched-btn');
11848        if (!btn) return;
11849        btn.addEventListener('click', function() {{
11850          fetch('/pick-directory?kind=reports')
11851            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
11852            .then(function(data) {{
11853              if (!data.cancelled && data.selected_path) {{
11854                var form = document.createElement('form');
11855                form.method = 'POST';
11856                form.action = '/watched-dirs/add';
11857                var ri = document.createElement('input');
11858                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
11859                var fi = document.createElement('input');
11860                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
11861                form.appendChild(ri); form.appendChild(fi);
11862                document.body.appendChild(form);
11863                if (window.__scanOverlay) window.__scanOverlay();
11864                form.submit();
11865              }}
11866            }})
11867            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
11868        }});
11869      }})();
11870
11871      // Settings / color-scheme modal
11872      (function() {{
11873        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'}}];
11874        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);}});}}
11875        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
11876        var btn=document.getElementById('settings-btn');if(!btn)return;
11877        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
11878        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>';
11879        document.body.appendChild(m);
11880        var g=document.getElementById('scheme-grid');
11881        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);}});
11882        var cl=document.getElementById('settings-close');
11883        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);}});}})();
11884        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');}});
11885        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
11886        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
11887      }})();
11888    }})();
11889
11890    var ROOTS = {roots_json};
11891    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
11892    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
11893    var allData = [];
11894
11895    // Populate root selector
11896    var rootSel = document.getElementById('root-sel');
11897    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
11898
11899    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();}}
11900    function fmtFull(n){{return Number(n).toLocaleString();}}
11901    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
11902
11903    // Tooltip
11904    var tt = document.createElement('div');
11905    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);';
11906    document.body.appendChild(tt);
11907    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
11908    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';}}
11909    function hideTT(){{tt.style.display='none';}}
11910    window.addEventListener('blur',function(){{hideTT();}});
11911    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
11912
11913    function statExact(compact, full){{
11914      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
11915    }}
11916    function statVal(n){{
11917      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
11918    }}
11919
11920    function updateStats(data){{
11921      var statsEl=document.getElementById('trend-stats');
11922      if(!statsEl)return;
11923      if(!data||!data.length){{statsEl.innerHTML='';return;}}
11924      var yKey=document.getElementById('y-sel').value;
11925      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
11926      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11927      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
11928      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
11929      var absDelta=Math.abs(delta);
11930      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
11931      var deltaExact=statExact(deltaCompact,deltaFull);
11932      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
11933      statsEl.innerHTML=
11934        '<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>'+
11935        '<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>'+
11936        '<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>'+
11937        '<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>';
11938    }}
11939
11940    var subSel = document.getElementById('sub-sel');
11941    var subLabel = document.getElementById('submodule-label');
11942
11943    function populateSubmodules(root){{
11944      if(!subSel||!subLabel)return;
11945      while(subSel.options.length>1)subSel.remove(1);
11946      subSel.value='';
11947      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
11948      fetch(url)
11949        .then(function(r){{return r.json();}})
11950        .then(function(subs){{
11951          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
11952          subs.forEach(function(s){{
11953            var o=document.createElement('option');
11954            o.value=s.name;
11955            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
11956            subSel.appendChild(o);
11957          }});
11958          subLabel.style.display='';
11959        }})
11960        .catch(function(){{subLabel.style.display='none';}});
11961    }}
11962
11963    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
11964
11965    function loadAndRender(){{
11966      var root = rootSel.value;
11967      var sub = subSel ? subSel.value : '';
11968      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
11969      document.getElementById('data-table-wrap').innerHTML='';
11970      var url = '/api/metrics/history?limit=100'
11971        + (root ? '&root='+encodeURIComponent(root) : '')
11972        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
11973      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
11974        allData = data;
11975        render(data);
11976        updateStats(data);
11977      }}).catch(function(){{
11978        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>';
11979      }});
11980    }}
11981
11982    function render(data){{
11983      var yKey = document.getElementById('y-sel').value;
11984      var xMode = document.getElementById('x-sel').value;
11985
11986      // Filter for tag/release mode
11987      var pts = data;
11988      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
11989
11990      // Sort oldest-first for the line chart
11991      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
11992
11993      var wrap = document.getElementById('chart-wrap');
11994      if(!pts.length){{
11995        var emptyMsg = (xMode === 'tag')
11996          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
11997          : 'No scan data found for the selected filters.';
11998        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
11999        renderTable([]);
12000        return;
12001      }}
12002
12003      var scaleEl=document.getElementById('scale-sel');
12004      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12005      renderTrendInto(wrap, pts, yKey, xMode, sc);
12006      renderTable(pts, yKey);
12007    }}
12008
12009    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12010    // Shared by the inline chart and the Full View modal so both render identically.
12011    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12012      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12013      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12014      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12015      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;
12016      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12017
12018      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12019
12020      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">';
12021      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>';
12022
12023      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12024
12025      // Grid + Y axis ticks
12026      for(var ti=0;ti<=5;ti++){{
12027        var gy=PT+CH-Math.round(ti/5*CH);
12028        var gv=Math.round(ti/5*maxY);
12029        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12030        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12031      }}
12032
12033      // X axis labels (every N-th point to avoid crowding)
12034      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12035      pts.forEach(function(d,i){{
12036        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12037        if(i%labelEvery===0||i===pts.length-1){{
12038          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)));
12039          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>';
12040        }}
12041      }});
12042
12043      // Axis label
12044      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12045      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>';
12046      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>';
12047
12048      // Area fill + line path
12049      var pathD='';
12050      pts.forEach(function(d,i){{
12051        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12052        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12053        pathD+=(i===0?'M':'L')+x+','+y;
12054      }});
12055      if(pts.length>1){{
12056        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12057        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12058      }}
12059      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12060
12061      // Data points (clickable) + permanent value labels
12062      var showLabels = pts.length <= 40;
12063      var labelEveryN = pts.length > 20 ? 2 : 1;
12064      pts.forEach(function(d,i){{
12065        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12066        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12067        var hasTags=d.tags&&d.tags.length>0;
12068        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12069        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12070        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+'"/>';
12071        if(showLabels && i%labelEveryN===0){{
12072          var lx=x, ly=y-r-5;
12073          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>';
12074        }}
12075      }});
12076
12077      svg+='</svg>';
12078      wrap.innerHTML=svg;
12079
12080      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12081      function lineYAt(mx){{
12082        var n=pts.length;
12083        if(n===0)return PT+CH;
12084        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12085        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12086        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12087        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12088        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12089        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12090        return y0+t*(y1-y0);
12091      }}
12092
12093      // SVG-level mousemove: show the value tooltip only when the pointer is over the
12094      // gradient fill (inside the chart and at/below the line) — never in the empty
12095      // space above the line. Cursor follows the same rule.
12096      (function(){{
12097        var svgEl=wrap.querySelector('svg');
12098        if(!svgEl)return;
12099        svgEl.addEventListener('mousemove',function(e){{
12100          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12101          var rect=svgEl.getBoundingClientRect();
12102          var scaleX=W/Math.max(rect.width,1);
12103          var scaleY=H/Math.max(rect.height,1);
12104          var mouseX=(e.clientX-rect.left)*scaleX;
12105          var mouseY=(e.clientY-rect.top)*scaleY;
12106          var ly=lineYAt(mouseX);
12107          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12108          svgEl.style.cursor='pointer';
12109          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12110          var d=pts[idx];
12111          var val=Number(d[yKey]);
12112          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12113          showTT(e,
12114            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12115            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12116            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12117          );
12118        }});
12119        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12120      }})();
12121
12122      // Attach point tooltips
12123      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12124        c.addEventListener('mouseover',function(e){{
12125          var d=pts[parseInt(this.dataset.idx)];
12126          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(''):'';
12127          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>':'';
12128          showTT(e,
12129            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12130            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12131            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12132            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12133          );
12134          this.setAttribute('r','8');
12135        }});
12136        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12137        c.addEventListener('mousemove',moveTT);
12138        c.addEventListener('click',function(){{
12139          var d=pts[parseInt(this.dataset.idx)];
12140          if(d.html_url) window.open(d.html_url,'_blank');
12141        }});
12142      }});
12143    }}
12144
12145    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12146    var shProjFilter='', shBranchFilter='';
12147
12148    function fmtPST(isoStr){{
12149      if(!isoStr)return'';
12150      var d=new Date(isoStr);
12151      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12152      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);}}
12153      function p(n){{return n<10?'0'+n:String(n);}}
12154      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++;}}}}
12155      var yr=d.getUTCFullYear();
12156      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12157      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12158      var isDST=d>=dstStart&&d<dstEnd;
12159      var off=isDST?-7*3600*1000:-8*3600*1000;
12160      var lbl=isDST?'PDT':'PST';
12161      var loc=new Date(d.getTime()+off);
12162      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12163    }}
12164
12165    function getShRows(){{
12166      var proj=shProjFilter.toLowerCase().trim();
12167      var branch=shBranchFilter;
12168      return shData.filter(function(d){{
12169        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12170        if(branch&&(d.branch||'')!==branch)return false;
12171        return true;
12172      }});
12173    }}
12174
12175    function renderShPage(){{
12176      var filtered=getShRows();
12177      if(shSortCol){{
12178        filtered.sort(function(a,b){{
12179          var va,vb;
12180          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12181          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12182          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12183          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12184          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12185          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12186        }});
12187      }}
12188      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12189      shPage=Math.min(shPage,totalPages);
12190      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12191      var visible=filtered.slice(start,end);
12192      var tbody=document.getElementById('sh-tbody');
12193      if(!tbody)return;
12194      tbody.innerHTML=visible.map(function(d){{
12195        var tsHtml=esc(fmtPST(d.timestamp));
12196        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>';
12197        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>';
12198        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12199        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12200        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12201        var reportCell='';
12202        if(d.html_url){{
12203          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12204          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>';}}
12205          reportCell+='</div>';
12206        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12207        if(d.submodule_links&&d.submodule_links.length){{
12208          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12209          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12210          reportCell+='</div></details>';
12211        }}
12212        return '<tr>'
12213          +'<td>'+tsHtml+'</td>'
12214          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12215          +'<td>'+runIdHtml+'</td>'
12216          +'<td>'+commitHtml+'</td>'
12217          +'<td>'+branchHtml+'</td>'
12218          +'<td>'+tags+'</td>'
12219          +'<td class="num">'+metricHtml+'</td>'
12220          +'<td class="report-cell">'+reportCell+'</td>'
12221          +'</tr>';
12222      }}).join('');
12223      var pgRange=document.getElementById('sh-pg-range');
12224      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12225      var pgInfo=document.getElementById('sh-pg-info');
12226      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12227      var pgBtns=document.getElementById('sh-pg-btns');
12228      if(pgBtns){{
12229        pgBtns.innerHTML='';
12230        function mkPgBtn(lbl,pg,active,disabled){{
12231          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12232          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12233          return b;
12234        }}
12235        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12236        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12237        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12238        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12239      }}
12240    }}
12241
12242    function wireTableBehavior(){{
12243      var pf=document.getElementById('sh-proj-filter');
12244      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12245      var bf=document.getElementById('sh-branch-filter');
12246      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12247      var rb=document.getElementById('sh-reset-btn');
12248      if(rb)rb.addEventListener('click',function(){{
12249        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12250        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12251        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12252        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');}});
12253        renderShPage();
12254      }});
12255      var pps=document.getElementById('sh-per-page');
12256      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12257      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12258      ths.forEach(function(th){{
12259        th.addEventListener('click',function(e){{
12260          if(e.target.classList.contains('col-resize-handle'))return;
12261          var col=th.dataset.col;
12262          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12263          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12264          th.classList.add('sort-'+shSortOrder);
12265          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12266          shPage=1;renderShPage();
12267        }});
12268      }});
12269      var table=document.getElementById('scan-history-table');
12270      if(!table)return;
12271      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12272      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12273      allThs.forEach(function(th,i){{
12274        var handle=th.querySelector('.col-resize-handle');
12275        if(!handle||!cols[i])return;
12276        var startX,startW;
12277        handle.addEventListener('mousedown',function(e){{
12278          e.stopPropagation();e.preventDefault();
12279          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12280          handle.classList.add('dragging');
12281          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12282          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12283          document.addEventListener('mousemove',onMove);
12284          document.addEventListener('mouseup',onUp);
12285        }});
12286      }});
12287    }}
12288
12289    function renderTable(pts, yKey){{
12290      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12291      var wrap=document.getElementById('data-table-wrap');
12292      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12293      var yLabel=Y_LABELS[yKey]||yKey||'';
12294      shData=pts.slice().reverse();
12295      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12296      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12297      var branches={{}};
12298      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12299      var branchOpts='<option value="">All branches</option>';
12300      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12301      wrap.innerHTML=
12302        '<div class="chart-section-header">SCAN HISTORY</div>'+
12303        '<div class="filter-row">'+
12304          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12305          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12306          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12307        '</div>'+
12308        '<div class="table-wrap">'+
12309        '<table id="scan-history-table" class="data-table">'+
12310        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12311        '<thead><tr id="sh-thead">'+
12312        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12313        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12314        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12315        '<th>Commit<div class="col-resize-handle"></div></th>'+
12316        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12317        '<th>Tags<div class="col-resize-handle"></div></th>'+
12318        '<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>'+
12319        '<th>Report<div class="col-resize-handle"></div></th>'+
12320        '</tr></thead>'+
12321        '<tbody id="sh-tbody"></tbody>'+
12322        '</table>'+
12323        '</div>'+
12324        '<div class="pagination">'+
12325          '<span class="pagination-info" id="sh-pg-info"></span>'+
12326          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12327          '<div style="display:flex;align-items:center;gap:8px;">'+
12328            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12329            '<select class="filter-select" id="sh-per-page">'+
12330              '<option value="10">10 per page</option>'+
12331              '<option value="25" selected>25 per page</option>'+
12332              '<option value="50">50 per page</option>'+
12333              '<option value="100">100 per page</option>'+
12334            '</select>'+
12335            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12336          '</div>'+
12337        '</div>';
12338      wireTableBehavior();
12339      renderShPage();
12340    }}
12341
12342    function exportXLSX(){{
12343      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12344      var xbtn=document.getElementById('export-xlsx-btn');
12345      var xorig=xbtn?xbtn.innerHTML:'';
12346      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12347      var root=rootSel.value;
12348      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12349      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12350        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12351        buildAndDownloadXLSX(cm);
12352      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12353    }}
12354
12355    function buildAndDownloadXLSX(churnMap){{
12356      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12357      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12358      // (sorted is newest-first), so a given project/commit appears at most once.
12359      var seenPC={{}},dedup=[];
12360      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12361      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12362      var s1R=dedup.map(function(d){{
12363        var c=churnMap[d.run_id]||{{}};
12364        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];
12365      }});
12366      var pm={{}};
12367      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12368      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'];
12369      var s2R=Object.keys(pm).map(function(p){{
12370        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12371        var lat=sc[sc.length-1],fst=sc[0];
12372        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12373        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);
12374        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];
12375      }});
12376      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12377      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12378      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12379      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12380    }}
12381
12382    function buildXLSX(sheets,chartRows,chartRows2){{
12383      function s2b(s){{return new TextEncoder().encode(s);}}
12384      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12385      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;}}
12386      function crc32(d){{
12387        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;}}}}
12388        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12389      }}
12390      function buildSheet(hdr,rows,drawRid,withCtrl){{
12391        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12392        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12393        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12394        x+='<row r="1">';
12395        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12396        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12397        x+='</row>';
12398        rows.forEach(function(row,ri){{
12399          var rn=ri+2;
12400          x+='<row r="'+rn+'">';
12401          row.forEach(function(cell,ci){{
12402            var addr=col2l(ci+1)+rn;
12403            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12404            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12405          }});
12406          if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12407          x+='</row>';
12408        }});
12409        x+='</sheetData>';
12410        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12411        return x+'</worksheet>';
12412      }}
12413      function buildChartXML(rows){{
12414        var sn="'Scan History'";
12415        var nr=rows.length,er=nr+1;
12416        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'}}];
12417        var catCol='C',catIdx=2;
12418        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12419        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">';
12420        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12421        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>';
12422        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12423        sd.forEach(function(s,i){{
12424          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12425          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>';
12426          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12427          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>';
12428          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12429          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12430          x+='</c:strCache></c:strRef></c:cat>';
12431          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+'"/>';
12432          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12433          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12434        }});
12435        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12436        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>';
12437        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>';
12438        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12439        return x;
12440      }}
12441      function buildChartXML2(rows){{
12442        var sn="'By Project'";
12443        var nr=rows.length,er=nr+1;
12444        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'}}];
12445        var catCol='A',catIdx=0;
12446        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12447        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">';
12448        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12449        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>';
12450        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12451        sd.forEach(function(s,i){{
12452          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12453          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>';
12454          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12455          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>';
12456          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12457          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12458          x+='</c:strCache></c:strRef></c:cat>';
12459          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+'"/>';
12460          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12461          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12462        }});
12463        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12464        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>';
12465        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>';
12466        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12467        return x;
12468      }}
12469      function buildChartXML3(rows){{
12470        var sn="'Scan History'";
12471        var nr=rows.length,er=nr+1;
12472        var catCol='C',catIdx=2;
12473        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12474        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">';
12475        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12476        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12477        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12478        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>";
12479        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12480        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>';
12481        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>';
12482        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12483        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12484        x+='</c:strCache></c:strRef></c:cat>';
12485        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+'"/>';
12486        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12487        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12488        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12489        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>';
12490        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>';
12491        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>';
12492        return x;
12493      }}
12494      function buildFocusSheet(drawRid){{
12495        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12496        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12497        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12498        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12499        x+='<sheetData><row r="1">';
12500        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12501        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12502        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12503        x+='</row></sheetData>';
12504        x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified"</formula1></dataValidation></dataValidations>';
12505        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12506        return x+'</worksheet>';
12507      }}
12508      var hasChart=!!(chartRows&&chartRows.length);
12509      var nr=hasChart?chartRows.length:0;
12510      var hasChart2=!!(chartRows2&&chartRows2.length);
12511      var nr2=hasChart2?chartRows2.length:0;
12512      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>';
12513      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"/>';
12514      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12515      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"/>';}}
12516      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"/>';}}
12517      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12518      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>';
12519      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12520      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"/>';}});
12521      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12522      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>';
12523      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12524      wbx+='</sheets></workbook>';
12525      var files=[
12526        {{name:'[Content_Types].xml',data:s2b(ct)}},
12527        {{name:'_rels/.rels',data:s2b(dotrels)}},
12528        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12529        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12530        {{name:'xl/styles.xml',data:s2b(styl)}}
12531      ];
12532      // Chart embedded directly in Scan History (sheet1); By Project is plain
12533      sheets.forEach(function(s,i){{
12534        var sx;
12535        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12536        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12537        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12538      }});
12539      if(hasChart){{
12540        var fromRow=nr+4,toRow=nr+34;
12541        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>')}});
12542        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12543        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">';
12544        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12545        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>';
12546        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>';
12547        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12548        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12549        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12550        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12551        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12552        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12553        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>')}});
12554        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12555        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>')}});
12556        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12557        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">';
12558        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12559        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>';
12560        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>';
12561        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12562        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12563        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12564        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12565        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12566        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12567        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>')}});
12568        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12569      }}
12570      if(hasChart2){{
12571        var fromRow2=nr2+4,toRow2=nr2+36;
12572        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>')}});
12573        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12574        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">';
12575        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12576        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>';
12577        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>';
12578        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12579        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12580        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12581        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12582        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12583        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12584        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>')}});
12585        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12586      }}
12587      var parts=[],offsets=[],total=0;
12588      files.forEach(function(f){{
12589        offsets.push(total);
12590        var nb=s2b(f.name),crc=crc32(f.data);
12591        var h=new DataView(new ArrayBuffer(30+nb.length));
12592        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12593        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12594        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12595        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12596        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12597        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12598        total+=30+nb.length+f.data.length;
12599      }});
12600      var cdStart=total;
12601      files.forEach(function(f,fi){{
12602        var nb=s2b(f.name),crc=crc32(f.data);
12603        var cd=new DataView(new ArrayBuffer(46+nb.length));
12604        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12605        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12606        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12607        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12608        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12609        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12610        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12611      }});
12612      var cdSz=total-cdStart;
12613      var eocd=new DataView(new ArrayBuffer(22));
12614      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12615      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12616      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12617      parts.push(new Uint8Array(eocd.buffer));
12618      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12619      var out=new Uint8Array(sz);var off=0;
12620      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12621      return out.buffer;
12622    }}
12623
12624    function trendTitleParts(){{
12625      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12626      var subSelEl=document.getElementById('sub-sel');
12627      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12628      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12629      var proj=(document.getElementById('root-sel').value)||'All projects';
12630      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12631      var cnt=(allData&&allData.length)||0;
12632      var now=new Date();
12633      function p2(n){{return(n<10?'0':'')+n;}}
12634      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12635      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12636    }}
12637
12638    function exportPNG(){{
12639      var svgEl=document.querySelector('#chart-wrap svg');
12640      if(!svgEl){{alert('No chart to export yet.');return;}}
12641      var svgStr=new XMLSerializer().serializeToString(svgEl);
12642      var vb=svgEl.viewBox.baseVal,scale=2;
12643      var headerH=84,footerH=36;
12644      var lw=(vb.width||900),lh=(vb.height||380);
12645      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12646      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12647      var url=URL.createObjectURL(blob);
12648      var img=new Image();
12649      var tp=trendTitleParts();
12650      img.onload=function(){{
12651        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12652        var ctx=canvas.getContext('2d');
12653        var cs=getComputedStyle(document.body);
12654        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12655        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12656        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12657        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12658        ctx.scale(scale,scale);
12659        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12660        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12661        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12662        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12663        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;
12664        ctx.drawImage(img,0,headerH);
12665        var fy=headerH+lh;
12666        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;
12667        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12668        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);
12669        ctx.textAlign='left';
12670        URL.revokeObjectURL(url);
12671        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12672      }};
12673      img.src=url;
12674    }}
12675
12676    function exportPDF(){{
12677      var svgEl=document.querySelector('#chart-wrap svg');
12678      if(!svgEl){{alert('No chart to export yet.');return;}}
12679      var tp=trendTitleParts();
12680      var svgStr=new XMLSerializer().serializeToString(svgEl);
12681      var statsEl=document.getElementById('trend-stats');
12682      var statsHtml=statsEl?statsEl.innerHTML:'';
12683      var yK=document.getElementById('y-sel').value;
12684      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12685      var yL=yLabels[yK]||yK;
12686      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12687      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>';
12688      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>';}});
12689      tableHtml+='</tbody></table>';
12690      var css='<style>'
12691        +'*{{box-sizing:border-box;}}'
12692        +'html,body{{margin:0;padding:0;}}'
12693        // Masthead/footer flow in document order — a position:fixed header repeats
12694        // on every printed page in Chromium and hides the rows beneath it on pages
12695        // 2+. The trend table's <thead> repeats per page natively instead.
12696        +'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;}}'
12697        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12698        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12699        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12700        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12701        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12702        +'.rep-body{{padding:22px 34px 0;}}'
12703        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12704        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12705        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12706        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12707        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12708        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12709        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12710        +'.stat-chip-tip{{display:none!important;}}'
12711        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12712        +'.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;}}'
12713        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12714        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12715        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12716        +'.rep-chart svg{{max-width:100%;height:auto;}}'
12717        +'.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;}}'
12718        +'.filter-row{{display:none!important;}}'
12719        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12720        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12721        +'th{{background:#f0e9e0;font-weight:800;}}'
12722        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12723        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12724        +'.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;}}'
12725        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12726        +'</style>';
12727      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12728        +'<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>'
12729        +'<div class="rep-body">'
12730        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12731        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12732        +'<div class="summary-strip">'+statsHtml+'</div>'
12733        +'<div class="rep-chart">'+svgStr+'</div>'
12734        +tableHtml
12735        +'</div>'
12736        +'<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>'
12737        +'</body></html>';
12738      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
12739    }}
12740
12741    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
12742      var el=document.getElementById(id);
12743      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
12744    }});
12745    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
12746    // tracks the container like the responsive Chart.js charts do.
12747    var _rsT=null;
12748    window.addEventListener('resize',function(){{
12749      if(_rsT)clearTimeout(_rsT);
12750      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
12751    }});
12752    rootSel.addEventListener('change',function(){{
12753      populateSubmodules(rootSel.value);
12754      loadAndRender();
12755    }});
12756    if(subSel)subSel.addEventListener('change',loadAndRender);
12757
12758    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
12759    (function(){{
12760      var fvBtn=document.getElementById('tr-chart-fv-btn');
12761      if(!fvBtn)return;
12762      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
12763      fvBtn.addEventListener('click',function(){{
12764        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
12765        var yKey=document.getElementById('y-sel').value;
12766        var xMode=document.getElementById('x-sel').value;
12767        var pts=allData;
12768        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
12769        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12770        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
12771        var tp=trendTitleParts();
12772        var ov=document.createElement('div');
12773        ov.className='tr-chart-full-modal';
12774        ov.innerHTML='<div class="tr-chart-full-inner">'
12775          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
12776          +'<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>'
12777          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
12778          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
12779          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
12780        document.body.appendChild(ov);
12781        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
12782        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
12783        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
12784        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
12785        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
12786      }});
12787    }})();
12788
12789    var xlsxBtn=document.getElementById('export-xlsx-btn');
12790    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
12791    var pngBtn=document.getElementById('export-png-btn');
12792    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
12793    var pdfBtn=document.getElementById('export-pdf-btn');
12794    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
12795
12796    // ── Clean-up modal ───────────────────────────────────────────────────────
12797    (function(){{
12798      var triggerBtn=document.getElementById('cleanup-runs-btn');
12799      if(!triggerBtn)return;
12800      var modal=document.createElement('div');
12801      modal.className='tr-modal-backdrop';
12802      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
12803        +'<div class="tr-modal-head">'
12804        +'<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>'
12805        +'<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>'
12806        +'</div>'
12807        +'<div class="tr-modal-body">'
12808        +'<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>'
12809        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
12810        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
12811        +'<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;">'
12812        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
12813        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
12814        +'</div>'
12815        +'<div class="tr-modal-foot">'
12816        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
12817        +'<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>'
12818        +'</div></div>';
12819      document.body.appendChild(modal);
12820      triggerBtn.addEventListener('click',function(){{
12821        document.getElementById('cleanup-status').style.display='none';
12822        modal.style.display='flex';
12823      }});
12824      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
12825      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12826      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
12827        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
12828        var confirmBtn=this;
12829        confirmBtn.disabled=true;
12830        var status=document.getElementById('cleanup-status');
12831        status.style.display='block';
12832        status.style.background='#dbeafe';status.style.color='#1e40af';
12833        status.textContent='Deleting\u2026';
12834        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
12835        .then(function(resp){{
12836          return resp.json().then(function(d){{
12837            if(resp.ok){{
12838              status.style.background='#dcfce7';status.style.color='#166534';
12839              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
12840              setTimeout(function(){{window.location.reload();}},1500);
12841            }}else{{
12842              status.style.background='#fee2e2';status.style.color='#991b1b';
12843              status.textContent='Error: '+(d.error||'Unexpected error');
12844              confirmBtn.disabled=false;
12845            }}
12846          }});
12847        }})
12848        .catch(function(e){{
12849          status.style.background='#fee2e2';status.style.color='#991b1b';
12850          status.textContent='Network error: '+String(e);
12851          confirmBtn.disabled=false;
12852        }});
12853      }});
12854    }})();
12855
12856    // ── Retention policy panel ────────────────────────────────────────────────
12857    (function(){{
12858      var triggerBtn=document.getElementById('retention-policy-btn');
12859      if(!triggerBtn)return;
12860      var modal=document.createElement('div');
12861      modal.className='tr-modal-backdrop';
12862      modal.style.zIndex='9001';
12863      modal.innerHTML=''
12864        +'<div class="tr-modal" style="max-width:640px;">'
12865        +'<div class="tr-modal-head">'
12866        +'<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>'
12867        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
12868        +'</div>'
12869        +'<div class="tr-modal-body">'
12870        +'<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>'
12871        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
12872        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
12873        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
12874        +'</div>'
12875        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
12876        +'<div>'
12877        +'<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>'
12878        +'<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;">'
12879        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
12880        +'</div>'
12881        +'<div>'
12882        +'<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>'
12883        +'<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;">'
12884        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
12885        +'</div>'
12886        +'</div>'
12887        +'<div style="margin-bottom:20px;">'
12888        +'<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>'
12889        +'<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;">'
12890        +'<option value="1">Every hour</option>'
12891        +'<option value="6">Every 6 hours</option>'
12892        +'<option value="12">Every 12 hours</option>'
12893        +'<option value="24" selected>Every 24 hours</option>'
12894        +'<option value="48">Every 2 days</option>'
12895        +'<option value="72">Every 3 days</option>'
12896        +'<option value="168">Every week</option>'
12897        +'</select>'
12898        +'</div>'
12899        +'<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>'
12900        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
12901        +'</div>'
12902        +'<div class="tr-modal-foot">'
12903        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
12904        +'<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>'
12905        +'<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>'
12906        +'</div>'
12907        +'</div>';
12908      document.body.appendChild(modal);
12909
12910      function rpShowStatus(msg,ok){{
12911        var s=document.getElementById('rp-status');
12912        s.style.display='block';
12913        s.style.background=ok?'#dcfce7':'#fee2e2';
12914        s.style.color=ok?'#166534':'#991b1b';
12915        s.textContent=msg;
12916      }}
12917      function fmtAgo(iso){{
12918        if(!iso)return'Never';
12919        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
12920        if(diff<60)return diff+'s ago';
12921        if(diff<3600)return Math.floor(diff/60)+'m ago';
12922        if(diff<86400)return Math.floor(diff/3600)+'h ago';
12923        return Math.floor(diff/86400)+'d ago';
12924      }}
12925      function loadPolicy(){{
12926        fetch('/api/cleanup-policy')
12927          .then(function(r){{return r.json();}})
12928          .then(function(d){{
12929            var p=d.policy;
12930            document.getElementById('rp-enabled').checked=p?p.enabled:false;
12931            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
12932            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
12933            var sel=document.getElementById('rp-interval');
12934            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;}}}}}}
12935            var lr=document.getElementById('rp-last-run');
12936            if(d.last_run_at){{
12937              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'):'');
12938            }}else{{
12939              lr.textContent='Auto-cleanup has not run yet.';
12940            }}
12941          }})
12942          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
12943      }}
12944
12945      triggerBtn.addEventListener('click',function(){{
12946        document.getElementById('rp-status').style.display='none';
12947        loadPolicy();
12948        modal.style.display='flex';
12949      }});
12950      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
12951      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
12952
12953      document.getElementById('rp-save-btn').addEventListener('click',function(){{
12954        var enabled=document.getElementById('rp-enabled').checked;
12955        var ageVal=document.getElementById('rp-max-age').value.trim();
12956        var countVal=document.getElementById('rp-max-count').value.trim();
12957        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
12958        if(enabled&&!ageVal&&!countVal){{
12959          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
12960          return;
12961        }}
12962        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
12963        var saveBtn=document.getElementById('rp-save-btn');
12964        saveBtn.disabled=true;
12965        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
12966          .then(function(r){{
12967            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
12968            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
12969          }})
12970          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12971          .finally(function(){{saveBtn.disabled=false;}});
12972      }});
12973
12974      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
12975        var btn=this;
12976        var orig=btn.innerHTML;
12977        btn.disabled=true;
12978        btn.textContent='Running\u2026';
12979        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
12980          .then(function(r){{return r.json();}})
12981          .then(function(d){{
12982            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
12983            loadPolicy();
12984          }})
12985          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
12986          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
12987      }});
12988    }})();
12989
12990    populateSubmodules(rootSel.value);
12991    loadAndRender();
12992
12993    (function randomizeWatermarks() {{
12994      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12995      if (!wms.length) return;
12996      var placed = [];
12997      function tooClose(top, left) {{
12998        for (var i = 0; i < placed.length; i++) {{
12999          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13000          if (dt < 16 && dl < 12) return true;
13001        }}
13002        return false;
13003      }}
13004      function pick(leftBand) {{
13005        for (var attempt = 0; attempt < 50; attempt++) {{
13006          var top = Math.random() * 88 + 2;
13007          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13008          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13009        }}
13010        var top = Math.random() * 88 + 2;
13011        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13012        placed.push([top, left]); return [top, left];
13013      }}
13014      var half = Math.floor(wms.length / 2);
13015      wms.forEach(function (img, i) {{
13016        var pos = pick(i < half);
13017        var size = Math.floor(Math.random() * 100 + 120);
13018        var rot = (Math.random() * 360).toFixed(1);
13019        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13020        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;
13021      }});
13022    }})();
13023    (function spawnCodeParticles() {{
13024      var container = document.getElementById('code-particles');
13025      if (!container) return;
13026      var snippets = [
13027        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13028        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13029        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13030        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13031        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13032      ];
13033      var count = 38;
13034      for (var i = 0; i < count; i++) {{
13035        (function(idx) {{
13036          var el = document.createElement('span');
13037          el.className = 'code-particle';
13038          el.textContent = snippets[idx % snippets.length];
13039          var left = Math.random() * 94 + 2;
13040          var top = Math.random() * 88 + 6;
13041          var dur = (Math.random() * 10 + 9).toFixed(1);
13042          var delay = (Math.random() * 18).toFixed(1);
13043          var rot = (Math.random() * 26 - 13).toFixed(1);
13044          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13045          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13046          container.appendChild(el);
13047        }})(i);
13048      }}
13049    }})();
13050  </script>
13051  <footer class="site-footer">
13052    local code analysis - metrics, history and reports
13053    &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>
13054    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13055    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13056    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13057    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13058  </footer>
13059  <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>
13060  {toast_assets}
13061</body>
13062</html>"##,
13063    );
13064
13065    Html(html).into_response()
13066}
13067
13068fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13069    use std::collections::HashMap;
13070    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13071        return vec![];
13072    }
13073    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13074    for rec in per_file_records {
13075        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13076            let e = totals.entry(lang.display_name().to_string()).or_default();
13077            e.0 += u64::from(cov.lines_found);
13078            e.1 += u64::from(cov.lines_hit);
13079        }
13080    }
13081    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13082    let mut pairs: Vec<(String, f64)> = totals
13083        .into_iter()
13084        .filter(|(_, (found, _))| *found > 0)
13085        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13086        .collect();
13087    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13088    pairs
13089        .iter()
13090        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13091        .collect()
13092}
13093
13094fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13095    let mut high = 0u64;
13096    let mut mid = 0u64;
13097    let mut low = 0u64;
13098    for rec in per_file_records {
13099        if let Some(cov) = &rec.coverage {
13100            if cov.lines_found == 0 {
13101                continue;
13102            }
13103            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13104            if pct >= 80.0 {
13105                high += 1;
13106            } else if pct >= 50.0 {
13107                mid += 1;
13108            } else {
13109                low += 1;
13110            }
13111        }
13112    }
13113    (high, mid, low)
13114}
13115
13116fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13117    let mut arr: Vec<serde_json::Value> = per_file_records
13118        .iter()
13119        .filter_map(|rec| {
13120            rec.coverage.as_ref().map(|cov| {
13121                let line_pct = if cov.lines_found > 0 {
13122                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13123                        / 10.0
13124                } else {
13125                    0.0
13126                };
13127                let fn_pct = if cov.functions_found > 0 {
13128                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13129                        .round()
13130                        / 10.0
13131                } else {
13132                    -1.0
13133                };
13134                serde_json::json!({
13135                    "rel": rec.relative_path,
13136                    "lang": rec.language.map_or("?", |l| l.display_name()),
13137                    "line_pct": line_pct,
13138                    "fn_pct": fn_pct,
13139                    "lhit": cov.lines_hit,
13140                    "lfound": cov.lines_found,
13141                    "fhit": cov.functions_hit,
13142                    "ffound": cov.functions_found,
13143                })
13144            })
13145        })
13146        .collect();
13147    arr.sort_by(|a, b| {
13148        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13149        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13150        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13151    });
13152    arr
13153}
13154
13155#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13156fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13157    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13158        .totals_by_language
13159        .iter()
13160        .filter(|l| l.test_count > 0)
13161        .collect();
13162    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13163    let lang_tests: Vec<serde_json::Value> = langs
13164        .iter()
13165        .map(|l| {
13166            let d = if l.code_lines > 0 {
13167                l.test_count as f64 / l.code_lines as f64 * 1000.0
13168            } else {
13169                0.0
13170            };
13171            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13172                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13173                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13174        })
13175        .collect();
13176    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13177    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13178    let t = &run.summary_totals;
13179    let total_tests = t.test_count;
13180    let density = if t.code_lines > 0 {
13181        total_tests as f64 / t.code_lines as f64 * 1000.0
13182    } else {
13183        0.0
13184    };
13185    let most_tested = langs.first().map_or_else(
13186        || "\u{2014}".to_string(),
13187        |l| l.language.display_name().to_string(),
13188    );
13189    let test_files: u64 = run
13190        .per_file_records
13191        .iter()
13192        .filter(|f| f.raw_line_categories.test_count > 0)
13193        .count() as u64;
13194    let cov_line = if t.coverage_lines_found > 0 {
13195        format!(
13196            "{:.1}",
13197            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13198        )
13199    } else {
13200        "0".to_string()
13201    };
13202    let cov_fn = if t.coverage_functions_found > 0 {
13203        format!(
13204            "{:.1}",
13205            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13206        )
13207    } else {
13208        "0".to_string()
13209    };
13210    let cov_branch = if t.coverage_branches_found > 0 {
13211        format!(
13212            "{:.1}",
13213            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13214        )
13215    } else {
13216        "0".to_string()
13217    };
13218    let has_cov = !cov_arr.is_empty();
13219    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13220    serde_json::json!({
13221        "totals": {
13222            "test_count": total_tests,
13223            "assertions": t.test_assertion_count,
13224            "suites": t.test_suite_count,
13225            "test_files": test_files,
13226            "total_files": t.files_analyzed,
13227            "density_str": format!("{density:.1}"),
13228            "most_tested": most_tested,
13229            "langs_with_tests": langs.len(),
13230            "cov_line": cov_line,
13231            "cov_fn": cov_fn,
13232            "cov_branch": cov_branch,
13233        },
13234        "lang_tests": lang_tests,
13235        "cov": cov_arr,
13236        "cov_tiers": {"high": high, "mid": mid, "low": low},
13237        "file_cov": file_cov_arr,
13238        "has_coverage": has_cov,
13239        "submodules": {},
13240    })
13241}
13242
13243#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13244fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13245    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13246        .language_summaries
13247        .iter()
13248        .filter(|l| l.test_count > 0)
13249        .collect();
13250    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13251    let lang_tests: Vec<serde_json::Value> = langs
13252        .iter()
13253        .map(|l| {
13254            let d = if l.code_lines > 0 {
13255                l.test_count as f64 / l.code_lines as f64 * 1000.0
13256            } else {
13257                0.0
13258            };
13259            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13260                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13261                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13262        })
13263        .collect();
13264    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13265    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13266    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13267    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13268    let density = if sub.code_lines > 0 {
13269        total_tests as f64 / sub.code_lines as f64 * 1000.0
13270    } else {
13271        0.0
13272    };
13273    let most_tested = langs.first().map_or_else(
13274        || "\u{2014}".to_string(),
13275        |l| l.language.display_name().to_string(),
13276    );
13277    serde_json::json!({
13278        "totals": {
13279            "test_count": total_tests,
13280            "assertions": total_assertions,
13281            "suites": total_suites,
13282            "test_files": test_files_approx,
13283            "total_files": sub.files_analyzed,
13284            "density_str": format!("{density:.1}"),
13285            "most_tested": most_tested,
13286            "langs_with_tests": langs.len(),
13287            "cov_line": "0",
13288            "cov_fn": "0",
13289            "cov_branch": "0",
13290        },
13291        "lang_tests": lang_tests,
13292        "cov": [],
13293        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13294        "has_coverage": false,
13295    })
13296}
13297
13298fn compute_cov_json_str(run: &AnalysisRun) -> String {
13299    use std::collections::HashMap;
13300    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13301    for rec in &run.per_file_records {
13302        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13303            let e = totals.entry(lang.display_name().to_string()).or_default();
13304            e.0 += u64::from(cov.lines_found);
13305            e.1 += u64::from(cov.lines_hit);
13306        }
13307    }
13308    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13309    let mut pairs: Vec<(String, f64)> = totals
13310        .into_iter()
13311        .filter(|(_, (found, _))| *found > 0)
13312        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13313        .collect();
13314    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13315    let parts: Vec<String> = pairs
13316        .iter()
13317        .map(|(lang, pct)| {
13318            let name = lang.replace('"', "\\\"");
13319            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13320        })
13321        .collect();
13322    format!("[{}]", parts.join(","))
13323}
13324
13325fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13326    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13327    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13328}
13329
13330fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13331    let mut entry = build_test_scope_entry(run);
13332    if !run.submodule_summaries.is_empty() {
13333        let subs: serde_json::Map<String, serde_json::Value> = run
13334            .submodule_summaries
13335            .iter()
13336            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13337            .collect();
13338        entry["submodules"] = serde_json::Value::Object(subs);
13339    }
13340    entry
13341}
13342
13343fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13344    let name = l.language.display_name().replace('"', "\\\"");
13345    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13346    let density = if l.code_lines > 0 {
13347        l.test_count as f64 / l.code_lines as f64 * 1000.0
13348    } else {
13349        0.0
13350    };
13351    format!(
13352        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13353        name = name,
13354        t = l.test_count,
13355        a = l.test_assertion_count,
13356        s = l.test_suite_count,
13357        c = l.code_lines,
13358        d = density,
13359        f = l.files,
13360    )
13361}
13362
13363fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13364    let Some(r) = run else {
13365        return "[]".to_string();
13366    };
13367    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13368        .totals_by_language
13369        .iter()
13370        .filter(|l| l.test_count > 0)
13371        .collect();
13372    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13373    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13374    format!("[{}]", parts.join(","))
13375}
13376
13377/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13378async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13379    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13380    scope_map.insert(
13381        "__all__".to_string(),
13382        latest_run.map_or_else(
13383            || {
13384                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13385                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13386                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13387                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13388                    "has_coverage":false,"submodules":{}})
13389            },
13390            build_test_scope_entry,
13391        ),
13392    );
13393    let all_roots: Vec<String> = {
13394        let reg = state.registry.lock().await;
13395        let mut seen = std::collections::BTreeSet::new();
13396        reg.entries
13397            .iter()
13398            .flat_map(|e| e.input_roots.iter().cloned())
13399            .filter(|r| seen.insert(r.clone()))
13400            .collect()
13401    };
13402    for root in &all_roots {
13403        let json_path = {
13404            let reg = state.registry.lock().await;
13405            reg.entries
13406                .iter()
13407                .find(|e| e.input_roots.iter().any(|r| r == root))
13408                .and_then(|e| e.json_path.clone())
13409        };
13410        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13411            let json_str = tokio::fs::read_to_string(&p).await.ok();
13412            json_str
13413                .as_deref()
13414                .and_then(|s| serde_json::from_str(s).ok())
13415        } else {
13416            None
13417        };
13418        if let Some(ref run) = run_for_root {
13419            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13420        }
13421    }
13422    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13423}
13424
13425// GET /test-metrics
13426#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13427#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13428async fn test_metrics_handler(
13429    State(state): State<AppState>,
13430    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13431) -> Response {
13432    auto_scan_watched_dirs(&state).await;
13433    let watched_dirs_list: Vec<String> = {
13434        let wd = state.watched_dirs.lock().await;
13435        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13436    };
13437    let latest_run: Option<AnalysisRun> = {
13438        let json_path = {
13439            let reg = state.registry.lock().await;
13440            reg.entries.first().and_then(|e| e.json_path.clone())
13441        };
13442        if let Some(p) = json_path {
13443            let json_str = tokio::fs::read_to_string(&p).await.ok();
13444            json_str
13445                .as_deref()
13446                .and_then(|s| serde_json::from_str(s).ok())
13447        } else {
13448            None
13449        }
13450    };
13451
13452    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13453    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13454
13455    // Build coverage chart JSON (per-language avg line coverage %).
13456    let cov_json: String = latest_run
13457        .as_ref()
13458        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13459        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13460
13461    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13462    let _cov_tier_json: String = latest_run
13463        .as_ref()
13464        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13465        .map_or_else(
13466            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13467            compute_cov_tier_json_str,
13468        );
13469
13470    let total_tests: u64 = latest_run
13471        .as_ref()
13472        .map_or(0, |r| r.summary_totals.test_count);
13473    let total_assertions: u64 = latest_run
13474        .as_ref()
13475        .map_or(0, |r| r.summary_totals.test_assertion_count);
13476    let total_suites: u64 = latest_run
13477        .as_ref()
13478        .map_or(0, |r| r.summary_totals.test_suite_count);
13479    let total_code: u64 = latest_run
13480        .as_ref()
13481        .map_or(0, |r| r.summary_totals.code_lines);
13482    let workspace_density: f64 = if total_code > 0 {
13483        total_tests as f64 / total_code as f64 * 1000.0
13484    } else {
13485        0.0
13486    };
13487    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13488        r.totals_by_language
13489            .iter()
13490            .filter(|l| l.test_count > 0)
13491            .count()
13492    });
13493    let most_tested: String = latest_run
13494        .as_ref()
13495        .and_then(|r| {
13496            r.totals_by_language
13497                .iter()
13498                .filter(|l| l.test_count > 0)
13499                .max_by_key(|l| l.test_count)
13500        })
13501        .map_or_else(
13502            || "\u{2014}".to_string(),
13503            |l| l.language.display_name().to_string(),
13504        );
13505    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13506        r.per_file_records
13507            .iter()
13508            .filter(|f| f.raw_line_categories.test_count > 0)
13509            .count() as u64
13510    });
13511    let total_files_analyzed: u64 = latest_run
13512        .as_ref()
13513        .map_or(0, |r| r.summary_totals.files_analyzed);
13514    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13515
13516    // Aggregated coverage percentages from summary_totals
13517    let cov_line_pct_str: String = latest_run
13518        .as_ref()
13519        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13520        .map_or_else(
13521            || "0".to_string(),
13522            |r| {
13523                format!(
13524                    "{:.1}",
13525                    r.summary_totals.coverage_lines_hit as f64
13526                        / r.summary_totals.coverage_lines_found as f64
13527                        * 100.0
13528                )
13529            },
13530        );
13531    let cov_fn_pct_str: String = latest_run
13532        .as_ref()
13533        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13534        .map_or_else(
13535            || "0".to_string(),
13536            |r| {
13537                format!(
13538                    "{:.1}",
13539                    r.summary_totals.coverage_functions_hit as f64
13540                        / r.summary_totals.coverage_functions_found as f64
13541                        * 100.0
13542                )
13543            },
13544        );
13545    let cov_branch_pct_str: String = latest_run
13546        .as_ref()
13547        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13548        .map_or_else(
13549            || "0".to_string(),
13550            |r| {
13551                format!(
13552                    "{:.1}",
13553                    r.summary_totals.coverage_branches_hit as f64
13554                        / r.summary_totals.coverage_branches_found as f64
13555                        * 100.0
13556                )
13557            },
13558        );
13559
13560    let cov_no_data_notice = if has_coverage {
13561        String::new()
13562    } else {
13563        String::from(
13564            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13565<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>
13566<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13567  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13568  <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>
13569  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13570  <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>
13571  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13572  <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>
13573  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13574  <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>
13575  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13576  <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>
13577</div>
13578<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13579</div>"#,
13580        )
13581    };
13582
13583    let workspace_density_str = format!("{workspace_density:.1}");
13584    let nonce = &csp_nonce;
13585    let toast_assets = sloc_toast_assets(nonce);
13586    let version = env!("CARGO_PKG_VERSION");
13587
13588    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13589    // of interactive controls — folder watching is managed by the host administrator.
13590    let watched_dirs_html: String = if state.server_mode {
13591        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()
13592    } else {
13593        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13594            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13595                .to_string()
13596        } else {
13597            watched_dirs_list
13598                .iter()
13599                .fold(String::new(), |mut s, d| {
13600                    use std::fmt::Write as _;
13601                    let escaped =
13602                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13603                    write!(
13604                        s,
13605                        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>"#
13606                    ).expect("write to String is infallible");
13607                    s
13608                })
13609        };
13610        format!(
13611            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>"#
13612        )
13613    };
13614
13615    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13616    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13617
13618    let html = format!(
13619        r#"<!doctype html>
13620<html lang="en">
13621<head>
13622  <meta charset="utf-8" />
13623  <meta name="viewport" content="width=device-width, initial-scale=1" />
13624  <title>OxideSLOC | Test Metrics</title>
13625  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13626  <style nonce="{nonce}">
13627    :root {{
13628      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13629      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13630      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13631      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13632      --info-bg:#eef3ff; --info-text:#4467d8;
13633    }}
13634    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13635    *{{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;}}
13636    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13637    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13638    .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;}}
13639    @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));}}}}
13640    .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);}}
13641    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13642    .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));}}
13643    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13644    .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;}}
13645    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13646    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13647    @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; }} }}
13648    .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;}}
13649    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13650    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13651    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13652    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13653    .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;}}
13654    .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;}}
13655    .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;}}
13656    .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;}}
13657    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13658    .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);}}
13659    .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;}}
13660    .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;}}
13661    .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;}}
13662    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13663    .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;}}
13664    .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);}}
13665    .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;}}
13666    .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;}}
13667    .tz-select:focus{{border-color:var(--oxide);}}
13668    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13669    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13670    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13671    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13672    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13673    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13674    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13675    .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);}}
13676    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13677    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13678    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13679    .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;}}
13680    .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;}}
13681    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13682    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13683    .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);}}
13684    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13685    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13686    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13687    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13688    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13689    .chart-canvas-wrap{{position:relative;height:280px;}}
13690    .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;}}
13691    .chart-no-data svg{{opacity:0.35;}}
13692    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13693    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13694    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13695    .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;}}
13696    .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;}}
13697    .data-table tr:last-child td{{border-bottom:none;}}
13698    .data-table tbody tr:hover td{{background:var(--surface-2);}}
13699    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13700    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13701    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13702    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13703    .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;}}
13704    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13705    .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);}}
13706    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13707    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13708    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13709    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13710    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13711    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13712    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13713    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13714    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13715    .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;}}
13716    .chart-select:focus{{border-color:var(--accent);}}
13717    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13718    .trend-canvas-wrap{{position:relative;height:260px;}}
13719    .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;}}
13720    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13721    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13722    .site-footer a{{color:var(--muted);}}
13723    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13724    .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;}}
13725    .btn:hover{{background:var(--surface-2);}}
13726    .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;}}
13727    .export-btn:hover{{background:var(--line);}}
13728    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13729    .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;}}
13730    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13731    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
13732    .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;}}
13733    .scope-sel:focus{{border-color:var(--accent);}}
13734    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
13735    .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;}}
13736    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
13737    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
13738    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
13739    .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;}}
13740    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
13741    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
13742    .watched-chip-rm:hover{{color:var(--oxide);}}
13743    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
13744    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
13745    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
13746    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
13747    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
13748    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
13749    .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;}}
13750    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
13751    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
13752    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
13753    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
13754    .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;}}
13755    .cov-file-search:focus{{border-color:var(--accent);}}
13756    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
13757    .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;}}
13758    body.dark-theme .cov-file-search{{background:var(--surface);}}
13759    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13760    .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;}}
13761    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13762    .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;}}
13763    .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);}}
13764    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
13765    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
13766    .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;}}
13767    .chart-modal-close:hover{{opacity:.7;}}
13768    body.dark-theme .chart-modal{{background:var(--surface);}}
13769  </style>
13770</head>
13771<body>
13772  <div class="background-watermarks" aria-hidden="true">
13773    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13774    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13775    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13776    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13777    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13778    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13779  </div>
13780  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13781  <div class="top-nav">
13782    <div class="top-nav-inner">
13783      <a class="brand" href="/">
13784        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13785        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
13786      </a>
13787      <div class="nav-right">
13788        <a class="nav-pill" href="/">Home</a>
13789        <div class="nav-dropdown">
13790          <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>
13791          <div class="nav-dropdown-menu">
13792            <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>
13793          </div>
13794        </div>
13795        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13796        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
13797        <div class="nav-dropdown">
13798          <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>
13799          <div class="nav-dropdown-menu">
13800            <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>
13801          </div>
13802        </div>
13803        <div class="server-status-wrap" id="server-status-wrap">
13804          <div class="nav-pill server-online-pill" id="server-status-pill">
13805            <span class="status-dot" id="status-dot"></span>
13806            <span id="server-status-label">Server</span>
13807            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
13808          </div>
13809          <div class="server-status-tip">
13810            OxideSLOC is running — accessible on your network.
13811            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
13812          </div>
13813        </div>
13814        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13815          <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>
13816        </button>
13817        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13818          <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>
13819          <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>
13820        </button>
13821      </div>
13822    </div>
13823  </div>
13824
13825  <div class="page">
13826    {watched_dirs_html}
13827    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
13828      <div class="scan-overlay-card">
13829        <div class="scan-spinner"></div>
13830        <div class="scan-overlay-text">Scanning folder…</div>
13831        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
13832      </div>
13833    </div>
13834    <style>
13835    .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);}}
13836    .scan-overlay.active{{display:flex;}}
13837    .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;}}
13838    .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;}}
13839    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
13840    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
13841    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
13842    </style>
13843    <div class="scope-bar">
13844      <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>
13845      <span class="scope-label">Scope</span>
13846      <div class="scope-sel-wrap">
13847        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
13848        <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);">
13849          <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>
13850          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
13851        </div>
13852      </div>
13853    </div>
13854    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13855      <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>
13856      <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>
13857      <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>
13858      <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>
13859    </div>
13860    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
13861      <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>
13862      <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>
13863      <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>
13864      <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>
13865    </div>
13866
13867    <div class="panel" id="viz-panel">
13868      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;display:flex;align-items:center;justify-content:space-between;">
13869        <span>Visualizations</span>
13870        <div style="display:flex;gap:8px;flex-wrap:wrap;">
13871          <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download test metrics as Excel workbook (.xlsx)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export Excel</button>
13872          <button type="button" class="export-btn" id="tm-export-png-btn" title="Save charts as PNG image"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Export PNG</button>
13873          <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export printable PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> Export PDF</button>
13874        </div>
13875      </div>
13876
13877      <div class="chart-box" style="margin-bottom:18px;">
13878        <div class="chart-box-header">
13879          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
13880          <div style="display:flex;gap:8px;align-items:center;">
13881            <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>
13882            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13883          </div>
13884        </div>
13885        <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>
13886        <div class="trend-controls-bar">
13887          <label>Y Metric:
13888            <select class="chart-select" id="tm-trend-y">
13889              <option value="test_count" selected>Test Definitions</option>
13890              <option value="code_lines">Code Lines</option>
13891            </select>
13892          </label>
13893          <label>X Axis:
13894            <select class="chart-select" id="tm-trend-x">
13895              <option value="commit" selected>By Commit</option>
13896              <option value="time">By Time</option>
13897            </select>
13898          </label>
13899          <label id="tm-sub-label" style="display:none;">Submodule:
13900            <select class="chart-select" id="tm-trend-sub">
13901              <option value="">All (project total)</option>
13902            </select>
13903          </label>
13904          <label>Chart Size:
13905            <select class="chart-select" id="tm-trend-size">
13906              <option value="200">Compact</option>
13907              <option value="260" selected>Normal</option>
13908              <option value="360">Large</option>
13909            </select>
13910          </label>
13911        </div>
13912        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
13913        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
13914      </div>
13915
13916      <div class="chart-row">
13917        <div class="chart-box">
13918          <div class="chart-box-header">
13919            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
13920            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13921          </div>
13922          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
13923          <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>
13924        </div>
13925        <div class="chart-box">
13926          <div class="chart-box-header">
13927            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
13928            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13929          </div>
13930          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
13931          <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>
13932        </div>
13933      </div>
13934
13935      <div class="chart-row">
13936        <div class="chart-box">
13937          <div class="chart-box-header">
13938            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
13939            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13940          </div>
13941          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
13942          <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>
13943        </div>
13944        <div class="chart-box" id="suites-chart-box">
13945          <div class="chart-box-header">
13946            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
13947            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
13948          </div>
13949          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
13950          <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>
13951        </div>
13952      </div>
13953
13954      <div class="chart-row">
13955        <div class="chart-box">
13956          <div class="chart-box-title">Test Files Breakdown</div>
13957          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
13958          <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>
13959        </div>
13960        <div class="chart-box">
13961          <div class="chart-box-title">Test Composition</div>
13962          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
13963          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
13964          <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>
13965        </div>
13966      </div>
13967    </div>
13968
13969    <div class="panel">
13970      <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap;">
13971        <h1 style="margin:0;">Test Metrics</h1>
13972        <div style="display:flex;gap:8px;flex-wrap:wrap;">
13973          <button type="button" class="export-btn" id="tm2-export-xlsx-btn" title="Download test metrics and LCOV coverage summary as an Excel workbook (.xlsx)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export Excel</button>
13974          <button type="button" class="export-btn" id="tm2-export-png-btn" title="Save the test metrics and coverage charts as a PNG image"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> Export PNG</button>
13975          <button type="button" class="export-btn" id="tm2-export-pdf-btn" title="Export a printable PDF of test metrics with the LCOV coverage summary"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> Export PDF</button>
13976        </div>
13977      </div>
13978      <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>
13979
13980      <div class="section-header">Language Breakdown</div>
13981      {cov_no_data_notice}
13982      <div style="overflow-x:auto;">
13983        <table class="data-table" id="lang-table">
13984          <thead><tr>
13985            <th>Language</th>
13986            <th class="num">Test Fns</th>
13987            <th class="num">Assertions</th>
13988            <th class="num">Suites</th>
13989            <th class="num">Code Lines</th>
13990            <th class="num">Files</th>
13991            <th class="num">Density / 1K</th>
13992            <th>Relative Density</th>
13993          </tr></thead>
13994          <tbody id="lang-tbody"></tbody>
13995        </table>
13996      </div>
13997    </div>
13998
13999    <div class="panel" id="cov-panel" style="display:none;">
14000      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14001      <div class="cov-gauge-row" id="cov-gauges">
14002        <div class="cov-gauge-card">
14003          <div class="cov-gauge-label">Line Coverage</div>
14004          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14005          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14006          <div class="cov-gauge-sub">Lines hit / instrumented</div>
14007          <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>
14008        </div>
14009        <div class="cov-gauge-card">
14010          <div class="cov-gauge-label">Function Coverage</div>
14011          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14012          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14013          <div class="cov-gauge-sub">Functions hit / found</div>
14014          <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>
14015        </div>
14016        <div class="cov-gauge-card">
14017          <div class="cov-gauge-label">Branch Coverage</div>
14018          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14019          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14020          <div class="cov-gauge-sub">Branches hit / found</div>
14021          <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>
14022        </div>
14023      </div>
14024      <div class="chart-row">
14025        <div class="chart-box">
14026          <div class="chart-box-title">Line Coverage % by Language</div>
14027          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14028        </div>
14029        <div class="chart-box">
14030          <div class="chart-box-title">Coverage Tier Distribution</div>
14031          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14032        </div>
14033      </div>
14034
14035      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14036      <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>
14037      <div class="cov-file-toolbar">
14038        <div class="cov-filter-tabs" id="cov-filter-tabs">
14039          <button class="cov-tab active" data-tier="all">All</button>
14040          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14041          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
14042          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
14043          <button class="cov-tab" data-tier="high">High (≥80%)</button>
14044        </div>
14045        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14046      </div>
14047      <div style="overflow-x:auto;">
14048        <table class="data-table" id="cov-file-table">
14049          <thead><tr>
14050            <th>File</th>
14051            <th>Lang</th>
14052            <th class="num">Line %</th>
14053            <th class="num">Lines Hit / Found</th>
14054            <th class="num">Fn %</th>
14055            <th class="num">Fns Hit / Found</th>
14056          </tr></thead>
14057          <tbody id="cov-file-tbody"></tbody>
14058        </table>
14059      </div>
14060      <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>
14061      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14062    </div>
14063
14064  </div>
14065
14066  <footer class="site-footer">
14067    local code analysis - metrics, history and reports
14068    &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>
14069    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14070    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14071    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14072    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14073  </footer>
14074
14075  <script nonce="{nonce}">
14076  (function() {{
14077    // Theme
14078    var b = document.body;
14079    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14080    var tgl = document.getElementById('theme-toggle');
14081    if (tgl) tgl.addEventListener('click', function() {{
14082      var d = b.classList.toggle('dark-theme');
14083      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14084    }});
14085
14086    // Watermarks
14087    (function() {{
14088      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14089      if (!wms.length) return;
14090      var placed = [];
14091      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;}}
14092      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];}}
14093      var half=Math.floor(wms.length/2);
14094      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;}});
14095    }})();
14096
14097    // Code particles
14098    (function() {{
14099      var container = document.getElementById('code-particles');
14100      if (!container) return;
14101      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14102      for (var i = 0; i < 36; i++) {{
14103        (function(idx) {{
14104          var el = document.createElement('span');
14105          el.className = 'code-particle';
14106          el.textContent = snippets[idx % snippets.length];
14107          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14108          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14109          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14110          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';
14111          container.appendChild(el);
14112        }})(i);
14113      }}
14114    }})();
14115
14116    // Settings modal
14117    (function() {{
14118      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'}}];
14119      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);}});}}
14120      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14121      var btn=document.getElementById('settings-btn');if(!btn)return;
14122      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14123      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>';
14124      document.body.appendChild(m);
14125      var g=document.getElementById('scheme-grid');
14126      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);}});
14127      var cl=document.getElementById('settings-close');
14128      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');}});
14129      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14130      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14131    }})();
14132
14133    // Watched folder picker
14134    (function(){{
14135      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');}};
14136      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);
14137    }})();
14138    (function() {{
14139      var btn = document.getElementById('add-watched-btn');
14140      if (!btn) return;
14141      btn.addEventListener('click', function() {{
14142        fetch('/pick-directory?kind=reports')
14143          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14144          .then(function(data) {{
14145            if (!data.cancelled && data.selected_path) {{
14146              var form = document.createElement('form');
14147              form.method = 'POST';
14148              form.action = '/watched-dirs/add';
14149              var ri = document.createElement('input');
14150              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14151              var fi = document.createElement('input');
14152              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14153              form.appendChild(ri); form.appendChild(fi);
14154              document.body.appendChild(form);
14155              if (window.__scanOverlay) window.__scanOverlay();
14156              form.submit();
14157            }}
14158          }})
14159          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14160      }});
14161    }})();
14162  }})();
14163  </script>
14164
14165  <script src="/static/chart.js" nonce="{nonce}"></script>
14166  <script nonce="{nonce}">
14167  (function() {{
14168    var SCOPE_DATA = {scope_data_json};
14169    var currentRoot = '__all__';
14170    var currentSub  = '';
14171    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14172    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14173    var ALL_CHARTS = [];
14174    var currentLangTests = [];
14175    var currentTrendPts = [];
14176
14177    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();}}
14178    function fmtFull(n){{return Number(n).toLocaleString();}}
14179    function isDark(){{return document.body.classList.contains('dark-theme');}}
14180    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14181    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14182    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14183
14184    function makeDlPlugin(fmtFn, anchor) {{
14185      return {{
14186        afterDatasetsDraw: function(chart) {{
14187          var ctx = chart.ctx;
14188          var tc = txtClr();
14189          chart.data.datasets.forEach(function(ds, di) {{
14190            var meta = chart.getDatasetMeta(di);
14191            meta.data.forEach(function(el, idx) {{
14192              var label = fmtFn(ds.data[idx], di, idx);
14193              if (label == null || label === '') return;
14194              ctx.save();
14195              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14196              ctx.fillStyle = tc;
14197              if (anchor === 'top') {{
14198                ctx.textAlign = 'center';
14199                ctx.textBaseline = 'bottom';
14200                ctx.fillText(String(label), el.x, el.y - 5);
14201              }} else {{
14202                ctx.textAlign = 'left';
14203                ctx.textBaseline = 'middle';
14204                ctx.fillText(String(label), el.x + 5, el.y);
14205              }}
14206              ctx.restore();
14207            }});
14208          }});
14209        }}
14210      }};
14211    }}
14212
14213    // Cursor: pointer over chart data, default over empty chart area.
14214    function chartCursor(e, els) {{
14215      var t = e.native && e.native.target;
14216      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14217    }}
14218    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14219
14220    // Plugin: draws % labels inside each doughnut slice.
14221    var donutPctPlugin = {{
14222      afterDatasetsDraw: function(chart) {{
14223        var ctx = chart.ctx;
14224        chart.data.datasets.forEach(function(ds, di) {{
14225          var meta = chart.getDatasetMeta(di);
14226          if (meta.hidden) return;
14227          var total = 0;
14228          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14229          if (!total) return;
14230          meta.data.forEach(function(arc, i) {{
14231            if (arc.hidden) return;
14232            var val = ds.data[i] || 0;
14233            var pct = val / total * 100;
14234            if (pct < 3) return;
14235            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14236            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14237            var tx = arc.x + midR * Math.cos(midAngle);
14238            var ty = arc.y + midR * Math.sin(midAngle);
14239            ctx.save();
14240            ctx.textAlign = 'center';
14241            ctx.textBaseline = 'middle';
14242            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14243            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14244            ctx.shadowBlur = 3;
14245            ctx.fillStyle = '#fff';
14246            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14247            ctx.restore();
14248          }});
14249        }});
14250      }}
14251    }};
14252
14253    function makeTmOverlay(title, subtitle, h) {{
14254      var overlay = document.createElement('div');
14255      overlay.className = 'chart-modal-overlay';
14256      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14257      var ch = Math.min(h || 560, maxH);
14258      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14259      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>';
14260      document.body.appendChild(overlay);
14261      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14262      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14263      return document.getElementById('tm-modal-canvas');
14264    }}
14265
14266    function getDataset() {{
14267      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14268      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14269      return r;
14270    }}
14271    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14272
14273    function showNoData(id, show) {{
14274      var el = document.getElementById(id);
14275      if (!el) return;
14276      var wrap = el.previousElementSibling;
14277      el.style.display = show ? '' : 'none';
14278      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14279    }}
14280
14281    function renderTestCharts(D) {{
14282      currentLangTests = D || [];
14283      testsChart = destroyChart(testsChart);
14284      densityChart = destroyChart(densityChart);
14285      if (!D || !D.length) {{
14286        showNoData('no-data-tests', true);
14287        showNoData('no-data-density', true);
14288        return;
14289      }}
14290      showNoData('no-data-tests', false);
14291      showNoData('no-data-density', false);
14292      var top15 = D.slice(0, 15);
14293      var canvas1 = document.getElementById('canvas-tests');
14294      if (canvas1) {{
14295        testsChart = new Chart(canvas1, {{
14296          type: 'bar',
14297          data: {{
14298            labels: top15.map(function(d){{ return d.lang; }}),
14299            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14300          }},
14301          options: {{
14302            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14303            layout: {{ padding: {{ right: 64 }} }},
14304            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14305            scales: {{
14306              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14307              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14308            }}
14309          }},
14310          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14311        }});
14312        ALL_CHARTS.push(testsChart);
14313      }}
14314      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14315      var canvas2 = document.getElementById('canvas-density');
14316      if (canvas2) {{
14317        densityChart = new Chart(canvas2, {{
14318          type: 'bar',
14319          data: {{
14320            labels: topD.map(function(d){{ return d.lang; }}),
14321            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 }}]
14322          }},
14323          options: {{
14324            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14325            layout: {{ padding: {{ right: 64 }} }},
14326            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14327            scales: {{
14328              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14329              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14330            }}
14331          }},
14332          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14333        }});
14334        ALL_CHARTS.push(densityChart);
14335      }}
14336    }}
14337
14338    function renderAssertionsChart(D) {{
14339      assertionsChart = destroyChart(assertionsChart);
14340      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14341      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14342      var canvas = document.getElementById('canvas-assertions');
14343      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14344      showNoData('no-data-assertions', false);
14345      assertionsChart = new Chart(canvas, {{
14346        type: 'bar',
14347        data: {{
14348          labels: top15.map(function(d){{ return d.lang; }}),
14349          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14350        }},
14351        options: {{
14352          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14353          layout: {{ padding: {{ right: 64 }} }},
14354          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14355          scales: {{
14356            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14357            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14358          }}
14359        }},
14360        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14361      }});
14362      ALL_CHARTS.push(assertionsChart);
14363    }}
14364
14365    function renderSuitesChart(D) {{
14366      suitesChart = destroyChart(suitesChart);
14367      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14368      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14369      var canvas = document.getElementById('canvas-suites');
14370      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14371      showNoData('no-data-suites', false);
14372      suitesChart = new Chart(canvas, {{
14373        type: 'bar',
14374        data: {{
14375          labels: top15.map(function(d){{ return d.lang; }}),
14376          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 }}]
14377        }},
14378        options: {{
14379          responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14380          layout: {{ padding: {{ right: 64 }} }},
14381          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14382          scales: {{
14383            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14384            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14385          }}
14386        }},
14387        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14388      }});
14389      ALL_CHARTS.push(suitesChart);
14390    }}
14391
14392    function renderFilesChart(totals) {{
14393      filesChart = destroyChart(filesChart);
14394      var canvas = document.getElementById('canvas-files');
14395      if (!canvas) return;
14396      var testF = totals.test_files || 0;
14397      var totalF = totals.total_files || 0;
14398      var nonTest = Math.max(0, totalF - testF);
14399      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14400      showNoData('no-data-files', false);
14401      var dark = isDark();
14402      filesChart = new Chart(canvas, {{
14403        type: 'doughnut',
14404        data: {{
14405          labels: ['Test Files', 'Non-Test Files'],
14406          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
14407        }},
14408        options: {{
14409          responsive: true, maintainAspectRatio: false, cutout: '62%',
14410          onHover: chartCursor,
14411          plugins: {{
14412            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14413              generateLabels: function(chart) {{
14414                var ds = chart.data.datasets[0];
14415                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14416                return chart.data.labels.map(function(lbl, i) {{
14417                  var val = ds.data[i] || 0;
14418                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14419                  return {{
14420                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14421                    fillStyle: ds.backgroundColor[i],
14422                    strokeStyle: ds.borderColor,
14423                    lineWidth: ds.borderWidth,
14424                    hidden: false,
14425                    index: i,
14426                    datasetIndex: 0
14427                  }};
14428                }});
14429              }}
14430            }},
14431              onHover: function(e, item, leg) {{
14432                var ch = leg.chart;
14433                var t = e.native && e.native.target;
14434                if (t) t.style.cursor = 'pointer';
14435                ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14436                ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14437                ch.update();
14438              }},
14439              onLeave: function(e, item, leg) {{
14440                var ch = leg.chart;
14441                var t = e.native && e.native.target;
14442                if (t) t.style.cursor = 'default';
14443                ch.setActiveElements([]);
14444                ch.tooltip.setActiveElements([], {{}});
14445                ch.update('none');
14446              }}
14447            }},
14448            tooltip: {{ callbacks: {{ label: function(ctx) {{
14449              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14450              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14451            }} }} }}
14452          }}
14453        }},
14454        plugins: [donutPctPlugin]
14455      }});
14456      ALL_CHARTS.push(filesChart);
14457    }}
14458
14459    function renderCompositionChart(totals) {{
14460      compositionChart = destroyChart(compositionChart);
14461      var canvas = document.getElementById('canvas-composition');
14462      if (!canvas) return;
14463      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14464      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14465      showNoData('no-data-composition', false);
14466      compositionChart = new Chart(canvas, {{
14467        type: 'bar',
14468        data: {{
14469          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14470          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14471        }},
14472        options: {{
14473          responsive: true, maintainAspectRatio: false,
14474          layout: {{ padding: {{ top: 22 }} }},
14475          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14476          scales: {{
14477            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14478            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14479          }}
14480        }},
14481        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14482      }});
14483      ALL_CHARTS.push(compositionChart);
14484    }}
14485
14486    function renderCovCharts(covD, tiers) {{
14487      covChart = destroyChart(covChart);
14488      tierChart = destroyChart(tierChart);
14489      var covCanvas = document.getElementById('canvas-cov');
14490      if (covCanvas && covD && covD.length) {{
14491        covChart = new Chart(covCanvas, {{
14492          type: 'bar',
14493          data: {{
14494            labels: covD.map(function(d){{ return d.lang; }}),
14495            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 }}]
14496          }},
14497          options: {{
14498            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14499            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14500            scales: {{
14501              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14502              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14503            }}
14504          }}
14505        }});
14506        ALL_CHARTS.push(covChart);
14507      }}
14508      var tierCanvas = document.getElementById('canvas-cov-tiers');
14509      if (tierCanvas && tiers) {{
14510        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14511        tierChart = new Chart(tierCanvas, {{
14512          type: 'doughnut',
14513          data: {{
14514            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14515            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
14516          }},
14517          options: {{
14518            responsive: true, maintainAspectRatio: false, cutout: '62%',
14519            onHover: chartCursor,
14520            plugins: {{
14521              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14522                onHover: function(e, item, leg) {{
14523                  var ch = leg.chart;
14524                  var t = e.native && e.native.target;
14525                  if (t) t.style.cursor = 'pointer';
14526                  ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14527                  ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14528                  ch.update();
14529                }},
14530                onLeave: function(e, item, leg) {{
14531                  var ch = leg.chart;
14532                  var t = e.native && e.native.target;
14533                  if (t) t.style.cursor = 'default';
14534                  ch.setActiveElements([]);
14535                  ch.tooltip.setActiveElements([], {{}});
14536                  ch.update('none');
14537                }}
14538              }},
14539              tooltip: {{ callbacks: {{ label: function(ctx) {{
14540                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14541                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14542              }} }} }}
14543            }}
14544          }},
14545          plugins: [donutPctPlugin]
14546        }});
14547        ALL_CHARTS.push(tierChart);
14548      }}
14549    }}
14550
14551    function buildLangTable(D) {{
14552      var tbody = document.getElementById('lang-tbody');
14553      if (!tbody) return;
14554      if (!D || !D.length) {{
14555        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>';
14556        return;
14557      }}
14558      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14559      tbody.innerHTML = D.map(function(d) {{
14560        var barW = Math.round(d.density / maxDensity * 120);
14561        return '<tr>' +
14562          '<td><strong>' + d.lang + '</strong></td>' +
14563          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14564          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14565          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14566          '<td class="num">' + fmtFull(d.code) + '</td>' +
14567          '<td class="num">' + fmtFull(d.files) + '</td>' +
14568          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14569          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14570          '</tr>';
14571      }}).join('');
14572    }}
14573
14574    var covFileData = [];
14575    var covFileTier = 'all';
14576    var covFileSearch = '';
14577
14578    function pctBadge(pct) {{
14579      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14580      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14581      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14582    }}
14583
14584    function buildCovFileTable() {{
14585      var tbody = document.getElementById('cov-file-tbody');
14586      var empty = document.getElementById('cov-file-empty');
14587      var count = document.getElementById('cov-file-count');
14588      if (!tbody) return;
14589      var srch = covFileSearch.toLowerCase();
14590      var filtered = covFileData.filter(function(f) {{
14591        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14592        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14593        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14594        if (covFileTier === 'high' && f.line_pct < 80) return false;
14595        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14596        return true;
14597      }});
14598      if (!filtered.length) {{
14599        tbody.innerHTML = '';
14600        if (empty) empty.style.display = '';
14601        if (count) count.textContent = '';
14602        return;
14603      }}
14604      if (empty) empty.style.display = 'none';
14605      var shown = Math.min(filtered.length, 500);
14606      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14607      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14608        var fnCol = f.fn_pct < 0
14609          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14610          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14611        return '<tr>' +
14612          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
14613          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14614          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14615          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14616          fnCol +
14617          '</tr>';
14618      }}).join('');
14619    }}
14620
14621    (function() {{
14622      var tabs = document.getElementById('cov-filter-tabs');
14623      if (tabs) {{
14624        tabs.addEventListener('click', function(e) {{
14625          var btn = e.target.closest('.cov-tab');
14626          if (!btn) return;
14627          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14628          btn.classList.add('active');
14629          covFileTier = btn.getAttribute('data-tier');
14630          buildCovFileTable();
14631        }});
14632      }}
14633      var srch = document.getElementById('cov-file-search');
14634      if (srch) {{
14635        srch.addEventListener('input', function() {{
14636          covFileSearch = this.value;
14637          buildCovFileTable();
14638        }});
14639      }}
14640    }})();
14641
14642    function updateCovGauges(t) {{
14643      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14644      var el;
14645      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14646      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14647      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14648      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14649      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14650      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14651    }}
14652
14653    function applyScope() {{
14654      var d = getDataset();
14655      var t = d.totals;
14656      var el;
14657      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14658      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14659      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14660      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14661      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
14662      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
14663      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
14664      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
14665      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
14666      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
14667      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
14668      renderTestCharts(d.lang_tests);
14669      renderAssertionsChart(d.lang_tests);
14670      renderSuitesChart(d.lang_tests);
14671      renderFilesChart(t);
14672      renderCompositionChart(t);
14673      buildLangTable(d.lang_tests);
14674      var covPanel = document.getElementById('cov-panel');
14675      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
14676      if (d.has_coverage) {{
14677        renderCovCharts(d.cov, d.cov_tiers);
14678        updateCovGauges(t);
14679        covFileData = d.file_cov || [];
14680        covFileTier = 'all';
14681        covFileSearch = '';
14682        var tabs = document.getElementById('cov-filter-tabs');
14683        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
14684        var srch = document.getElementById('cov-file-search');
14685        if (srch) srch.value = '';
14686        buildCovFileTable();
14687      }}
14688      loadTrend();
14689    }}
14690
14691    // Populate scope-root-sel from SCOPE_DATA keys
14692    (function() {{
14693      var sel = document.getElementById('scope-root-sel');
14694      if (!sel) return;
14695      Object.keys(SCOPE_DATA).forEach(function(k) {{
14696        if (k === '__all__') return;
14697        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
14698      }});
14699    }})();
14700
14701    document.getElementById('scope-root-sel').addEventListener('change', function() {{
14702      currentRoot = this.value;
14703      currentSub = '';
14704      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14705      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
14706      var subWrap = document.getElementById('scope-sub-wrap');
14707      var subSel  = document.getElementById('scope-sub-sel');
14708      subSel.innerHTML = '<option value="">Entire project</option>';
14709      if (subNames.length) {{
14710        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
14711        subWrap.style.display = 'flex';
14712      }} else {{
14713        subWrap.style.display = 'none';
14714      }}
14715      applyScope();
14716    }});
14717
14718    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
14719      currentSub = this.value;
14720      applyScope();
14721    }});
14722
14723    var allTrendData = [];
14724
14725    var TM_Y_META = {{
14726      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
14727      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
14728    }};
14729
14730    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
14731    function hexRgb(hex) {{
14732      var h = String(hex).replace('#', '');
14733      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
14734      var n = parseInt(h, 16);
14735      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
14736    }}
14737    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
14738    // tint at the top to transparent at the bottom (no flat solid block).
14739    function tmTrendGradient(ctx2, chartArea, color) {{
14740      var rgb = hexRgb(color);
14741      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
14742      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
14743      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
14744      g.addColorStop(1,   'rgba(' + rgb + ',0)');
14745      return g;
14746    }}
14747
14748    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
14749    // so linear interpolation between adjacent points matches the drawn line).
14750    function tmLineYAt(chart, px) {{
14751      var meta = chart.getDatasetMeta(0);
14752      if (!meta || !meta.data || !meta.data.length) return null;
14753      var d = meta.data;
14754      if (px <= d[0].x) return d[0].y;
14755      for (var i = 1; i < d.length; i++) {{
14756        if (px <= d[i].x) {{
14757          var span = d[i].x - d[i - 1].x;
14758          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
14759          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
14760        }}
14761      }}
14762      return d[d.length - 1].y;
14763    }}
14764
14765    // Plugin: only show the tooltip / finger cursor when the pointer is over the
14766    // gradient fill (inside the plot and at/below the line) — never in the empty
14767    // space above the line. Outside the fill we retype the event as 'mouseout' so
14768    // the core interaction dismisses any active tooltip on its own.
14769    var tmFillGuard = {{
14770      id: 'tmFillGuard',
14771      beforeEvent: function(chart, args) {{
14772        var e = args.event;
14773        if (!e || e.type !== 'mousemove') return;
14774        var ca = chart.chartArea;
14775        if (!ca) return;
14776        var inFill = false;
14777        if (e.x >= ca.left && e.x <= ca.right) {{
14778          var ly = tmLineYAt(chart, e.x);
14779          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
14780        }}
14781        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
14782        if (!inFill) {{ e.type = 'mouseout'; }}
14783      }}
14784    }};
14785
14786    // Single source of truth for the test-metrics trend chart config so the inline
14787    // chart and the Full View modal render identically (straight segments, gradient
14788    // fill, white-ringed points, gradient-only interactivity).
14789    function buildTmTrendConfig(pts, ctrl, meta) {{
14790      return {{
14791        type: 'line',
14792        data: {{
14793          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
14794          datasets: [{{
14795            label: meta.label,
14796            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
14797            borderColor: meta.color,
14798            borderWidth: 2.5,
14799            backgroundColor: function(context) {{
14800              var ca = context.chart.chartArea;
14801              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
14802              return tmTrendGradient(context.chart.ctx, ca, meta.color);
14803            }},
14804            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
14805            pointBorderColor: '#fff',
14806            pointBorderWidth: 2,
14807            pointRadius: 6,
14808            pointHoverRadius: 9,
14809            pointHoverBorderWidth: 2.5,
14810            fill: true, tension: 0
14811          }}]
14812        }},
14813        options: {{
14814          responsive: true, maintainAspectRatio: false,
14815          layout: {{ padding: {{ top: 22 }} }},
14816          interaction: {{ mode: 'index', intersect: false }},
14817          plugins: {{
14818            legend: {{ display: false }},
14819            tooltip: {{
14820              mode: 'index', intersect: false,
14821              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
14822            }}
14823          }},
14824          scales: {{
14825            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
14826            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14827          }}
14828        }},
14829        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
14830      }};
14831    }}
14832
14833    function getTrendControls() {{
14834      var ySel    = document.getElementById('tm-trend-y');
14835      var xSel    = document.getElementById('tm-trend-x');
14836      var sizeSel = document.getElementById('tm-trend-size');
14837      var subSel  = document.getElementById('tm-trend-sub');
14838      return {{
14839        yKey:    ySel    ? ySel.value    : 'test_count',
14840        xMode:   xSel    ? xSel.value    : 'commit',
14841        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
14842        submod:  subSel  ? subSel.value  : ''
14843      }};
14844    }}
14845
14846    function makeTrendLabel(d, xMode) {{
14847      if (xMode === 'commit') {{
14848        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
14849      }}
14850      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
14851    }}
14852
14853    function buildTrend(data) {{
14854      allTrendData = data || [];
14855      renderTrend();
14856    }}
14857
14858    function renderTrend() {{
14859      var data = allTrendData;
14860      var ctrl = getTrendControls();
14861      var trendCanvas = document.getElementById('canvas-trend');
14862      var trendWrap   = document.getElementById('trend-canvas-wrap');
14863      var trendEmpty  = document.getElementById('trend-empty');
14864
14865      // Apply chart size
14866      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
14867
14868      // Filter by submodule if selected (entries from project_label match)
14869      var pts = data.slice().reverse();
14870      if (ctrl.submod) {{
14871        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
14872      }}
14873
14874      currentTrendPts = pts;
14875
14876      if (!pts.length) {{
14877        if (trendCanvas) trendCanvas.style.display = 'none';
14878        if (trendEmpty) trendEmpty.style.display = '';
14879        return;
14880      }}
14881      if (trendCanvas) trendCanvas.style.display = '';
14882      if (trendEmpty) trendEmpty.style.display = 'none';
14883
14884      trendChart = destroyChart(trendChart);
14885      if (!trendCanvas) return;
14886
14887      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14888
14889      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
14890      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
14891      ALL_CHARTS.push(trendChart);
14892
14893      // Populate submodule selector from unique project_labels
14894      var subSel = document.getElementById('tm-trend-sub');
14895      var subLabel = document.getElementById('tm-sub-label');
14896      if (subSel && data.length) {{
14897        var projects = [];
14898        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
14899        if (projects.length > 1) {{
14900          var curVal = subSel.value;
14901          subSel.innerHTML = '<option value="">All (project total)</option>';
14902          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
14903          if (subLabel) subLabel.style.display = '';
14904        }} else {{
14905          if (subLabel) subLabel.style.display = 'none';
14906        }}
14907      }}
14908    }}
14909
14910    // ── Full View expand buttons ──────────────────────────────────────────────
14911    (function() {{
14912      var btn = document.getElementById('tests-expand-btn');
14913      if (!btn) return;
14914      btn.addEventListener('click', function() {{
14915        var D = currentLangTests;
14916        if (!D || !D.length) return;
14917        var top15 = D.slice(0, 15);
14918        var h = Math.max(320, top15.length * 36 + 80);
14919        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
14920        if (!canvas) return;
14921        new Chart(canvas, {{
14922          type: 'bar',
14923          data: {{
14924            labels: top15.map(function(d){{ return d.lang; }}),
14925            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14926          }},
14927          options: {{
14928            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14929            layout: {{ padding: {{ right: 72 }} }},
14930            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14931            scales: {{
14932              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
14933              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14934            }}
14935          }},
14936          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14937        }});
14938      }});
14939    }})();
14940
14941    (function() {{
14942      var btn = document.getElementById('density-expand-btn');
14943      if (!btn) return;
14944      btn.addEventListener('click', function() {{
14945        var D = currentLangTests;
14946        if (!D || !D.length) return;
14947        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
14948        var h = Math.max(320, topD.length * 36 + 80);
14949        var canvas = makeTmOverlay('Test Density (per 1\u202f000 code lines) \u2014 Full View', topD.length + ' languages', h);
14950        if (!canvas) return;
14951        new Chart(canvas, {{
14952          type: 'bar',
14953          data: {{
14954            labels: topD.map(function(d){{ return d.lang; }}),
14955            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 }}]
14956          }},
14957          options: {{
14958            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
14959            layout: {{ padding: {{ right: 72 }} }},
14960            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14961            scales: {{
14962              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14963              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
14964            }}
14965          }},
14966          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14967        }});
14968      }});
14969    }})();
14970
14971    (function() {{
14972      var btn = document.getElementById('trend-expand-btn');
14973      if (!btn) return;
14974      btn.addEventListener('click', function() {{
14975        var pts = currentTrendPts;
14976        if (!pts || !pts.length) return;
14977        var ctrl = getTrendControls();
14978        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
14979        var title = meta.label + ' Trend \u2014 Full View';
14980        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
14981        if (!canvas) return;
14982        // Reuse the exact inline-chart config so Full View matches the default view
14983        // (straight segments + gradient-only interactivity), just larger.
14984        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
14985      }});
14986    }})();
14987
14988    (function() {{
14989      var btn = document.getElementById('assertions-expand-btn');
14990      if (!btn) return;
14991      btn.addEventListener('click', function() {{
14992        var D = currentLangTests;
14993        if (!D || !D.length) return;
14994        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14995        if (!top15.length) return;
14996        var h = Math.max(320, top15.length * 36 + 80);
14997        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
14998        if (!canvas) return;
14999        new Chart(canvas, {{
15000          type: 'bar',
15001          data: {{
15002            labels: top15.map(function(d){{ return d.lang; }}),
15003            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15004          }},
15005          options: {{
15006            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
15007            layout: {{ padding: {{ right: 72 }} }},
15008            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15009            scales: {{
15010              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15011              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15012            }}
15013          }},
15014          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15015        }});
15016      }});
15017    }})();
15018
15019    (function() {{
15020      var btn = document.getElementById('suites-expand-btn');
15021      if (!btn) return;
15022      btn.addEventListener('click', function() {{
15023        var D = currentLangTests;
15024        if (!D || !D.length) return;
15025        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15026        if (!top15.length) return;
15027        var h = Math.max(320, top15.length * 36 + 80);
15028        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15029        if (!canvas) return;
15030        new Chart(canvas, {{
15031          type: 'bar',
15032          data: {{
15033            labels: top15.map(function(d){{ return d.lang; }}),
15034            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 }}]
15035          }},
15036          options: {{
15037            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
15038            layout: {{ padding: {{ right: 72 }} }},
15039            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15040            scales: {{
15041              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15042              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15043            }}
15044          }},
15045          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15046        }});
15047      }});
15048    }})();
15049
15050    // Wire trend control selectors — re-render without re-fetching
15051    (function() {{
15052      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15053        var el = document.getElementById(id);
15054        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15055      }});
15056    }})();
15057
15058    function loadTrend() {{
15059      var url = '/api/metrics/history?limit=100';
15060      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15061      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15062        buildTrend(data);
15063        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15064        var btn = document.getElementById('multi-compare-trend-btn');
15065        if (btn) {{
15066          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15067          if (ids.length >= 2) {{
15068            btn.style.display = '';
15069            btn.onclick = function() {{
15070              // Reverse so oldest first (API returns newest first).
15071              var sorted = ids.slice().reverse();
15072              if (sorted.length === 2) {{
15073                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15074              }} else {{
15075                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15076              }}
15077            }};
15078          }} else {{
15079            btn.style.display = 'none';
15080          }}
15081        }}
15082      }}).catch(function(){{
15083        var trendEmpty = document.getElementById('trend-empty');
15084        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15085      }});
15086    }}
15087
15088    // Re-render charts on theme toggle
15089    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15090      setTimeout(function() {{
15091        ALL_CHARTS.forEach(function(c) {{
15092          if (c && c.options && c.options.scales) {{
15093            Object.values(c.options.scales).forEach(function(ax) {{
15094              if (ax.grid) ax.grid.color = clr();
15095              if (ax.ticks) ax.ticks.color = txtClr();
15096            }});
15097            c.update();
15098          }}
15099        }});
15100      }}, 80);
15101    }});
15102
15103    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15104    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15105    function tmExportMeta() {{
15106      var sel = document.getElementById('scope-sel');
15107      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15108      if (!proj || proj === '__all__') proj = 'All projects';
15109      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15110      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15111      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15112      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15113      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15114    }}
15115
15116    function exportTmXLSX() {{
15117      var D = currentLangTests;
15118      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15119      var t = tmExportMeta();
15120      function s2b(s) {{ return new TextEncoder().encode(s); }}
15121      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
15122      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; }}
15123      function crc32(d) {{
15124        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;}}}}
15125        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15126      }}
15127      // Store all cells as strings so Excel left-aligns uniformly.
15128      function cs(addr, val, bold) {{
15129        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15130      }}
15131      // Build an Excel Table XML definition for a given sheet range and columns.
15132      function makeTableXml(tblId, name, ref, cols) {{
15133        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15134        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15135        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15136        x+='<autoFilter ref="'+ref+'"/>';
15137        x+='<tableColumns count="'+cols.length+'">';
15138        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15139        x+='</tableColumns>';
15140        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15141        return x+'</table>';
15142      }}
15143      // Worksheet XML with optional Excel Table part reference.
15144      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15145        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15146        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15147        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15148        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15149        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15150        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>';}});
15151        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>';}}
15152        x+='</sheetData>';
15153        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15154        return x+'</worksheet>';
15155      }}
15156
15157      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15158      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15159      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15160      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15161      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15162      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15163
15164      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15165      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15166      var sheets=[];
15167
15168      // Sheet: Summary
15169      var sumHdr=['Metric','Value'];
15170      var sumRows=[
15171        ['Project / Scope', t.proj],
15172        ['Export Date', t.full],
15173        ['Test Functions', Number(totTests).toLocaleString()],
15174        ['Assertions', Number(totAssert).toLocaleString()],
15175        ['Test Suites', Number(totSuites).toLocaleString()],
15176        ['Languages with Tests', String(D.length)],
15177        ['Total Code Lines', Number(totCode).toLocaleString()],
15178        ['Average Density (per 1K)', String(avgDensity)],
15179      ];
15180      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15181
15182      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15183      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15184      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)];}});
15185      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15186      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15187
15188      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15189      var covDs=(typeof getDataset==='function')?getDataset():null;
15190      if(covDs&&covDs.has_coverage){{
15191        var covT=covDs.totals||{{}};
15192        var covSumHdr=['Metric','Value'];
15193        var covSumRows=[
15194          ['Line Coverage', (covT.cov_line||'0')+'%'],
15195          ['Function Coverage', (covT.cov_fn||'0')+'%'],
15196          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15197        ];
15198        if(covDs.cov_tiers){{
15199          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15200          covSumRows.push(['Files Moderate (50–79%)', String(covDs.cov_tiers.mid||0)]);
15201          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15202        }}
15203        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15204
15205        if(covDs.cov&&covDs.cov.length){{
15206          var covLangHdr=['Language','Line Coverage %'];
15207          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15208          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15209        }}
15210        if(covFileData&&covFileData.length){{
15211          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15212          var covFileRows=covFileData.map(function(f){{
15213            var noFn=f.fn_pct<0;
15214            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)];
15215          }});
15216          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15217        }}
15218      }}
15219
15220      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>';
15221      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>';
15222
15223      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15224      var files=[];
15225      var ctOverrides='', wbSheetTags='', wbRelTags='';
15226      sheets.forEach(function(sh,i){{
15227        var n=i+1;
15228        var lastCol=col2l(sh.hdr.length);
15229        var ref='A1:'+lastCol+(sh.rows.length+1);
15230        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15231        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15232        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>';
15233        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15234        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15235        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15236        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15237        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15238        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15239        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15240      }});
15241      var styleRid='rId'+(sheets.length+1);
15242      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>';
15243      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>';
15244      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>';
15245      files.unshift(
15246        {{name:'[Content_Types].xml',data:s2b(ct)}},
15247        {{name:'_rels/.rels',data:s2b(dotrels)}},
15248        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15249        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15250        {{name:'xl/styles.xml',data:s2b(styl)}}
15251      );
15252      var parts=[],offsets=[],total=0;
15253      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;}});
15254      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;}});
15255      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));
15256      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;}});
15257      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15258      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15259      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15260      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15261    }}
15262
15263    function exportTmPNG() {{
15264      // Map canvas IDs to display titles
15265      var CHART_TITLES = {{
15266        'canvas-trend':       'TEST COUNT TREND',
15267        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15268        'canvas-density':     'TEST DENSITY (PER 1,000 CODE LINES)',
15269        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15270        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15271        'canvas-files':       'TEST FILES BREAKDOWN',
15272        'canvas-composition': 'TEST COMPOSITION',
15273        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
15274        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
15275      }};
15276      // Coverage canvases are only appended when the LCOV panel is visible (has data).
15277      var covPanelEl=document.getElementById('cov-panel');
15278      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15279      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15280      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15281      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(function(c){{return c&&c.width>0&&c.style.display!=='none';}});
15282      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15283      var t=tmExportMeta();
15284      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15285      var trendCanvas=document.getElementById('canvas-trend');
15286      var hasTrend=trendCanvas&&trendCanvas.width>0&&trendCanvas.style.display!=='none';
15287      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15288      var TOTAL_W=COLW*2+GAP;
15289      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15290      TREND_H=Math.min(Math.max(200,TREND_H),340);
15291      // Per-row chart heights (2-col grid)
15292      var gridRows=Math.ceil(gridCanvases.length/2);
15293      var rowHeights=[];
15294      for(var ri=0;ri<gridRows;ri++){{
15295        var rh=240;
15296        for(var ci=0;ci<2;ci++){{
15297          var cv=gridCanvases[ri*2+ci];
15298          if(cv&&cv.width>0){{
15299            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15300            rh=Math.max(rh,Math.min(420,nat));
15301          }}
15302        }}
15303        rowHeights.push(rh);
15304      }}
15305      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15306      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15307      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15308      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15309      var ctx=out.getContext('2d');
15310      var cs2=getComputedStyle(document.body);
15311      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15312      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15313      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15314
15315      // Background
15316      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15317
15318      // Orange header block
15319      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15320      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15321      ctx.fillText('Test Metrics — '+t.proj,22,42);
15322      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15323      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15324      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15325
15326      // Helper: draw a section title label
15327      function drawTitle(label, x, y, w) {{
15328        ctx.save();
15329        ctx.fillStyle=oxide;
15330        ctx.font='700 11px '+TM_FONT;
15331        ctx.textBaseline='middle';
15332        ctx.textAlign='left';
15333        ctx.letterSpacing='0.07em';
15334        ctx.fillText(label, x+2, y+TITLE_H/2);
15335        // Underline
15336        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15337        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15338        ctx.globalAlpha=1;
15339        ctx.restore();
15340      }}
15341
15342      var yOff=HEADER_H;
15343
15344      // Trend chart (full width)
15345      if(hasTrend){{
15346        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15347        yOff+=TITLE_H;
15348        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15349        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15350        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15351        ctx.drawImage(surf,0,yOff);
15352        yOff+=TREND_H+ROW_PAD;
15353      }}
15354
15355      // Grid charts (2-col), each cell gets title + chart
15356      for(var gi=0;gi<gridRows;gi++){{
15357        var rh2=rowHeights[gi];
15358        // Draw row titles and charts
15359        for(var gci=0;gci<2;gci++){{
15360          var idx2=gi*2+gci;
15361          if(idx2>=gridCanvases.length)continue;
15362          var gcv=gridCanvases[idx2];
15363          var gx=gci*(COLW+GAP);
15364          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15365        }}
15366        yOff+=TITLE_H;
15367        for(var gci2=0;gci2<2;gci2++){{
15368          var idx3=gi*2+gci2;
15369          if(idx3>=gridCanvases.length)continue;
15370          var gcv2=gridCanvases[idx3];
15371          var gx2=gci2*(COLW+GAP);
15372          var natW=gcv2.width,natH=gcv2.height;
15373          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15374          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15375          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15376          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15377          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15378          ctx.drawImage(surf2,gx2,yOff);
15379        }}
15380        yOff+=rh2+ROW_PAD;
15381      }}
15382
15383      // Dark footer
15384      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15385      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15386      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15387
15388      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15389      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15390    }}
15391
15392    function exportTmPDF(ev) {{
15393      var D=currentLangTests;
15394      var t=tmExportMeta();
15395      var strips=document.querySelectorAll('.summary-strip');
15396      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15397      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15398      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15399      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15400      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15401      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15402      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15403      var rows='';
15404      (D||[]).forEach(function(d){{
15405        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15406          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15407          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15408          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15409          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15410          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15411          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15412      }});
15413      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15414        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15415        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15416        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15417        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15418        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15419        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15420      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>';
15421      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15422        +'html,body{{height:100%;margin:0;}}'
15423        +'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;}}'
15424        +'.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;}}'
15425        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15426        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15427        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15428        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15429        +'.rep-body{{padding:20px 32px;flex:1;}}'
15430        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15431        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15432        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15433        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15434        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15435        +'.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;}}'
15436        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15437        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15438        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15439        +'.n{{text-align:right;}}'
15440        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15441        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15442        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15443        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15444        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15445        +'.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;}}'
15446        +'</style>';
15447      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15448      var covDs=(typeof getDataset==='function')?getDataset():null;
15449      var covHtml='';
15450      if(covDs&&covDs.has_coverage){{
15451        var covT=covDs.totals||{{}};
15452        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15453          +'<div class="cov-strip">'
15454          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15455          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15456          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15457          +'</div>';
15458        if(covFileData&&covFileData.length){{
15459          var cfrows='';
15460          covFileData.forEach(function(f){{
15461            var noFn=f.fn_pct<0;
15462            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15463              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15464              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15465              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15466              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15467          }});
15468          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15469            +'<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>';
15470        }}
15471      }}
15472      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15473        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15474        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15475        +'<div class="rep-body">'+statsHtml
15476        +'<div class="section-hdr">Language Breakdown</div>'
15477        +tableHtml+covHtml+'</div>'
15478        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15479        +'</body></html>';
15480      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15481      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15482      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15483    }}
15484
15485    (function() {{
15486      var xBtn=document.getElementById('tm-export-xlsx-btn');
15487      var pngBtn=document.getElementById('tm-export-png-btn');
15488      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15489      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15490      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15491      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15492      // Test Metrics panel export controls — share the same handlers, which now
15493      // fold in the LCOV Coverage Summary when the current scope has coverage.
15494      var xBtn2=document.getElementById('tm2-export-xlsx-btn');
15495      var pngBtn2=document.getElementById('tm2-export-png-btn');
15496      var pdfBtn2=document.getElementById('tm2-export-pdf-btn');
15497      if(xBtn2)xBtn2.addEventListener('click',exportTmXLSX);
15498      if(pngBtn2)pngBtn2.addEventListener('click',exportTmPNG);
15499      if(pdfBtn2)pdfBtn2.addEventListener('click',exportTmPDF);
15500    }})();
15501
15502    applyScope();
15503  }})();
15504  </script>
15505  <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>
15506  {toast_assets}
15507</body>
15508</html>"#,
15509    );
15510    (
15511        [(axum::http::header::CACHE_CONTROL, "no-store")],
15512        Html(html),
15513    )
15514        .into_response()
15515}
15516
15517// ── Embeddable widget ─────────────────────────────────────────────────────────
15518// Protected. Returns a self-contained HTML page suitable for iframing inside
15519// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15520//
15521// GET /embed/summary?run_id=<uuid>&theme=dark
15522
15523#[derive(Deserialize)]
15524struct EmbedQuery {
15525    run_id: Option<String>,
15526    theme: Option<String>,
15527}
15528
15529async fn embed_handler(
15530    State(state): State<AppState>,
15531    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15532    Query(query): Query<EmbedQuery>,
15533) -> Response {
15534    let entry = {
15535        let reg = state.registry.lock().await;
15536        query.run_id.as_ref().map_or_else(
15537            || reg.entries.first().cloned(),
15538            |id| reg.find_by_run_id(id).cloned(),
15539        )
15540    };
15541
15542    let Some(entry) = entry else {
15543        return Html(
15544            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15545                .to_string(),
15546        )
15547        .into_response();
15548    };
15549
15550    let dark = query.theme.as_deref() == Some("dark");
15551    let languages: Vec<(String, u64, u64)> = entry
15552        .json_path
15553        .as_ref()
15554        .and_then(|p| read_json(p).ok())
15555        .map(|run| {
15556            run.totals_by_language
15557                .iter()
15558                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15559                .collect()
15560        })
15561        .unwrap_or_default();
15562
15563    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15564}
15565
15566fn render_embed_widget(
15567    entry: &RegistryEntry,
15568    languages: &[(String, u64, u64)],
15569    dark: bool,
15570    csp_nonce: &str,
15571) -> String {
15572    let s = &entry.summary;
15573    let total = s.code_lines + s.comment_lines + s.blank_lines;
15574    let code_pct = s
15575        .code_lines
15576        .checked_mul(100)
15577        .and_then(|n| n.checked_div(total))
15578        .unwrap_or(0);
15579
15580    let (bg, fg, surface, muted, border) = if dark {
15581        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15582    } else {
15583        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15584    };
15585
15586    let mut lang_rows = String::new();
15587    for (name, files, code) in languages {
15588        write!(
15589            lang_rows,
15590            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15591            escape_html(name),
15592            format_number(*files),
15593            format_number(*code),
15594        )
15595        .ok();
15596    }
15597
15598    let lang_table = if lang_rows.is_empty() {
15599        String::new()
15600    } else {
15601        format!(
15602            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15603        )
15604    };
15605
15606    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15607    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15608    let project_esc = escape_html(&entry.project_label);
15609    let code_lines = format_number(s.code_lines);
15610    let comment_lines = format_number(s.comment_lines);
15611    let files = format_number(s.files_analyzed);
15612    let code_raw = s.code_lines;
15613    let comment_raw = s.comment_lines;
15614    let blank_raw = s.blank_lines;
15615
15616    format!(
15617        r#"<!doctype html>
15618<html lang="en">
15619<head>
15620  <meta charset="utf-8">
15621  <meta name="viewport" content="width=device-width,initial-scale=1">
15622  <title>OxideSLOC &mdash; {project_esc}</title>
15623  <script src="/static/chart.js"></script>
15624  <style nonce="{csp_nonce}">
15625    *{{box-sizing:border-box;margin:0;padding:0}}
15626    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15627    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15628    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15629    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15630    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15631    .card .v{{font-size:18px;font-weight:700}}
15632    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15633    .row{{display:flex;gap:12px;align-items:flex-start}}
15634    .pie{{width:120px;height:120px;flex-shrink:0}}
15635    .lt{{border-collapse:collapse;width:100%;flex:1}}
15636    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15637    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15638    .n{{text-align:right}}
15639    .footer{{margin-top:10px;color:{muted};font-size:10px}}
15640  </style>
15641</head>
15642<body>
15643  <h2>{project_esc}</h2>
15644  <div class="sub">{timestamp} &middot; run {run_short}</div>
15645  <div class="cards">
15646    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15647    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15648    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15649    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15650  </div>
15651  <div class="row">
15652    <canvas class="pie" id="c"></canvas>
15653    {lang_table}
15654  </div>
15655  <div class="footer">oxide-sloc</div>
15656  <script nonce="{csp_nonce}">
15657    new Chart(document.getElementById('c'),{{
15658      type:'doughnut',
15659      data:{{
15660        labels:['Code','Comments','Blank'],
15661        datasets:[{{
15662          data:[{code_raw},{comment_raw},{blank_raw}],
15663          backgroundColor:['#4a78ee','#b35428','#aaa'],
15664          borderWidth:0
15665        }}]
15666      }},
15667      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
15668    }});
15669  </script>
15670</body>
15671</html>"#
15672    )
15673}
15674
15675/// Returns a process-wide mutex unique to `dir`, so that two requests writing
15676/// artifacts into the *same* output directory (e.g. re-ingesting an identical
15677/// `run_id`) serialize instead of corrupting each other's files. Directories that
15678/// differ never contend, so legitimate parallel analyses keep their throughput.
15679fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
15680    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
15681        OnceLock::new();
15682    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
15683    let mut guard = map
15684        .lock()
15685        .unwrap_or_else(std::sync::PoisonError::into_inner);
15686    guard
15687        .entry(dir.to_path_buf())
15688        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
15689        .clone()
15690}
15691
15692#[allow(clippy::too_many_lines)]
15693fn persist_run_artifacts(
15694    run: &sloc_core::AnalysisRun,
15695    report_html: &str,
15696    run_dir: &Path,
15697    report_title: &str,
15698    file_stem: &str,
15699    result_context: RunResultContext,
15700) -> Result<(RunArtifacts, PendingPdf)> {
15701    // Serialize concurrent writers targeting this same output directory so their
15702    // file writes cannot interleave and corrupt one another.
15703    let dir_lock = output_dir_lock(run_dir);
15704    let _dir_guard = dir_lock
15705        .lock()
15706        .unwrap_or_else(std::sync::PoisonError::into_inner);
15707
15708    // Root dir + organised subdirectories.
15709    let html_dir = run_dir.join("html");
15710    let pdf_dir = run_dir.join("pdf");
15711    let excel_dir = run_dir.join("excel");
15712    let json_dir = run_dir.join("json");
15713    let submodules_dir = run_dir.join("submodules");
15714    for dir in &[
15715        run_dir,
15716        &html_dir,
15717        &pdf_dir,
15718        &excel_dir,
15719        &json_dir,
15720        &submodules_dir,
15721    ] {
15722        fs::create_dir_all(dir)
15723            .with_context(|| format!("failed to create directory {}", dir.display()))?;
15724    }
15725
15726    // HTML report in html/.
15727    let html_path = {
15728        let path = html_dir.join(format!("report_{file_stem}.html"));
15729        fs::write(&path, report_html)
15730            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
15731        Some(path)
15732    };
15733
15734    // JSON result in json/.
15735    let json_path = {
15736        let path = json_dir.join(format!("result_{file_stem}.json"));
15737        let json = serde_json::to_string_pretty(run)
15738            .context("failed to serialize analysis run to JSON")?;
15739        fs::write(&path, json)
15740            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
15741        Some(path)
15742    };
15743
15744    // PDF in pdf/.
15745    let (pdf_path, pending_pdf) = {
15746        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
15747        match write_pdf_from_run(run, &pdf_dest) {
15748            Ok(()) => {
15749                eprintln!(
15750                    "[oxide-sloc][pdf] native PDF written to {}",
15751                    pdf_dest.display()
15752                );
15753                (Some(pdf_dest), None)
15754            }
15755            Err(native_err) => {
15756                eprintln!(
15757                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
15758                );
15759                let source_html_path = html_path
15760                    .as_ref()
15761                    .expect("html_path always Some here")
15762                    .clone();
15763                let pending = Some((source_html_path, pdf_dest.clone(), false));
15764                (Some(pdf_dest), pending)
15765            }
15766        }
15767    };
15768
15769    // CSV and XLSX in excel/.
15770    let csv_path = {
15771        let path = excel_dir.join(format!("report_{file_stem}.csv"));
15772        if let Err(e) = sloc_report::write_csv(run, &path) {
15773            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
15774            None
15775        } else {
15776            Some(path)
15777        }
15778    };
15779
15780    let xlsx_path = {
15781        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
15782        if let Err(e) = sloc_report::write_xlsx(run, &path) {
15783            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
15784            None
15785        } else {
15786            Some(path)
15787        }
15788    };
15789
15790    // Scan config in json/.
15791    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
15792
15793    // Eagerly generate sub-reports before index.html so relative links work.
15794    if run.effective_configuration.discovery.submodule_breakdown {
15795        let run_id = &run.tool.run_id;
15796        for s in &run.submodule_summaries {
15797            build_submodule_row(s, run, run_id, run_dir);
15798        }
15799    }
15800
15801    // index.html at root — offline static export of the result-page dashboard.
15802    generate_offline_index(
15803        run,
15804        run_dir,
15805        file_stem,
15806        html_path.as_deref(),
15807        pdf_path.as_deref(),
15808        json_path.as_deref(),
15809        scan_config_path.as_deref(),
15810        &result_context,
15811    );
15812
15813    Ok((
15814        RunArtifacts {
15815            output_dir: run_dir.to_path_buf(),
15816            html_path,
15817            pdf_path,
15818            json_path,
15819            csv_path,
15820            xlsx_path,
15821            scan_config_path,
15822            report_title: report_title.to_string(),
15823            result_context,
15824        },
15825        pending_pdf,
15826    ))
15827}
15828
15829/// Render a static offline result-page dashboard and write it as `index.html` at
15830/// the root of the run output directory so business users can open it from disk.
15831#[allow(clippy::too_many_arguments)]
15832#[allow(clippy::too_many_lines)]
15833#[allow(clippy::similar_names)]
15834fn generate_offline_index(
15835    run: &sloc_core::AnalysisRun,
15836    run_dir: &Path,
15837    file_stem: &str,
15838    html_path: Option<&Path>,
15839    pdf_path: Option<&Path>,
15840    json_path: Option<&Path>,
15841    scan_config_path: Option<&Path>,
15842    result_context: &RunResultContext,
15843) {
15844    let prev_entry = &result_context.prev_entry;
15845    let prev_scan_count = result_context.prev_scan_count;
15846    let project_path = &result_context.project_path;
15847
15848    let scan_delta = prev_entry.as_ref().and_then(|prev| {
15849        prev.json_path
15850            .as_ref()
15851            .and_then(|p| read_json(p).ok())
15852            .map(|prev_run| compute_delta(&prev_run, run))
15853    });
15854
15855    let files_analyzed = run.per_file_records.len() as u64;
15856    let files_skipped = run.skipped_file_records.len() as u64;
15857    let totals = sum_lang_totals(run);
15858
15859    let DeltaFields {
15860        prev_fa_str,
15861        prev_fs_str,
15862        prev_pl_str,
15863        prev_cl_str,
15864        prev_cml_str,
15865        prev_bl_str,
15866        delta_fa_str,
15867        delta_fa_class,
15868        delta_fs_str,
15869        delta_fs_class,
15870        delta_pl_str,
15871        delta_pl_class,
15872        delta_cl_str,
15873        delta_cl_class,
15874        delta_cml_str,
15875        delta_cml_class,
15876        delta_bl_str,
15877        delta_bl_class,
15878        delta_lines_added,
15879        delta_lines_removed,
15880        delta_lines_net_str,
15881        delta_lines_net_class,
15882    } = compute_delta_fields(
15883        prev_entry.as_ref(),
15884        &totals,
15885        files_analyzed,
15886        files_skipped,
15887        scan_delta.as_ref(),
15888    );
15889
15890    let git_commit_url = git_commit_url_for(run);
15891    let git_branch_url = git_branch_url_for(run);
15892    let scan_performed_by = scan_performed_by(run);
15893
15894    // Convert absolute path to relative from run_dir (for file:// navigation).
15895    let make_rel = |p: Option<&Path>| -> Option<String> {
15896        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
15897            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
15898    };
15899
15900    let run_id = &run.tool.run_id;
15901
15902    // Submodule rows with relative paths into submodules/.
15903    let submodule_rows: Vec<SubmoduleRow> = run
15904        .submodule_summaries
15905        .iter()
15906        .map(|s| {
15907            let safe = sanitize_project_label(&s.name);
15908            let key = format!("sub_{safe}");
15909            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
15910            SubmoduleRow {
15911                name: s.name.clone(),
15912                relative_path: s.relative_path.clone(),
15913                files_analyzed: s.files_analyzed,
15914                code_lines: s.code_lines,
15915                comment_lines: s.comment_lines,
15916                blank_lines: s.blank_lines,
15917                total_physical_lines: s.total_physical_lines,
15918                html_url: if sub_path.exists() {
15919                    Some(format!("submodules/{key}.html"))
15920                } else {
15921                    None
15922                },
15923            }
15924        })
15925        .collect();
15926
15927    let lang_chart_json = build_lang_chart_json(run);
15928
15929    let scan_config_rel =
15930        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
15931
15932    let template = ResultTemplate {
15933        version: env!("CARGO_PKG_VERSION"),
15934        report_title: run.effective_configuration.reporting.report_title.clone(),
15935        project_path: project_path.clone(),
15936        output_dir: display_path(run_dir),
15937        run_id: run_id.clone(),
15938        run_id_short: run_id
15939            .split('-')
15940            .next_back()
15941            .unwrap_or(run_id)
15942            .chars()
15943            .take(7)
15944            .collect(),
15945        files_analyzed,
15946        files_skipped,
15947        physical_lines: totals.physical_lines,
15948        code_lines: totals.code_lines,
15949        comment_lines: totals.comment_lines,
15950        blank_lines: totals.blank_lines,
15951        mixed_lines: totals.mixed_lines,
15952        functions: totals.functions,
15953        classes: totals.classes,
15954        variables: totals.variables,
15955        imports: totals.imports,
15956        html_url: make_rel(html_path),
15957        pdf_url: make_rel(pdf_path),
15958        json_url: make_rel(json_path),
15959        html_download_url: make_rel(html_path),
15960        pdf_download_url: make_rel(pdf_path),
15961        json_download_url: make_rel(json_path),
15962        html_path: html_path.map(display_path),
15963        json_path: json_path.map(display_path),
15964        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
15965        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
15966        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
15967        prev_fa_str,
15968        prev_fs_str,
15969        prev_pl_str,
15970        prev_cl_str,
15971        prev_cml_str,
15972        prev_bl_str,
15973        delta_fa_str,
15974        delta_fa_class,
15975        delta_fs_str,
15976        delta_fs_class,
15977        delta_pl_str,
15978        delta_pl_class,
15979        delta_cl_str,
15980        delta_cl_class,
15981        delta_cml_str,
15982        delta_cml_class,
15983        delta_bl_str,
15984        delta_bl_class,
15985        delta_lines_added,
15986        delta_lines_removed,
15987        delta_lines_net_str,
15988        delta_lines_net_class,
15989        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
15990        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
15991        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
15992        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
15993        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
15994        git_branch: run.git_branch.clone(),
15995        git_branch_url,
15996        git_commit: run.git_commit_short.clone(),
15997        git_commit_long: run.git_commit_long.clone(),
15998        git_author: run.git_commit_author.clone(),
15999        git_commit_url,
16000        scan_performed_by,
16001        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16002        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16003        os_display: format!(
16004            "{} / {}",
16005            run.environment.operating_system, run.environment.architecture
16006        ),
16007        test_count: run.summary_totals.test_count,
16008        test_assertion_count: run.summary_totals.test_assertion_count,
16009        current_scan_number: prev_scan_count + 1,
16010        prev_scan_count,
16011        submodule_rows,
16012        pdf_generating: false,
16013        scan_config_url: scan_config_rel,
16014        lang_chart_json,
16015        scatter_chart_json: build_scatter_chart_json(run),
16016        semantic_chart_json: build_semantic_chart_json(run),
16017        submodule_chart_json: build_submodule_chart_json(run),
16018        has_submodule_data: !run.submodule_summaries.is_empty(),
16019        has_semantic_data: run
16020            .totals_by_language
16021            .iter()
16022            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16023        csp_nonce: String::new(),
16024        confluence_configured: false,
16025        server_mode: false,
16026        report_header_footer: run
16027            .effective_configuration
16028            .reporting
16029            .report_header_footer
16030            .clone(),
16031        is_offline: true,
16032        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16033        lsloc: run.summary_totals.lsloc,
16034        uloc: run.uloc,
16035        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16036        duplicate_group_count: run.duplicate_groups.len(),
16037        has_cocomo: run.cocomo.is_some(),
16038        cocomo_effort_str: run
16039            .cocomo
16040            .as_ref()
16041            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16042        cocomo_duration_str: run
16043            .cocomo
16044            .as_ref()
16045            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16046        cocomo_staff_str: run
16047            .cocomo
16048            .as_ref()
16049            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16050        cocomo_ksloc_str: run
16051            .cocomo
16052            .as_ref()
16053            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16054        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16055            || "Organic".to_string(),
16056            |c| cocomo_mode_label(c.mode).to_string(),
16057        ),
16058        cocomo_mode_tooltip: run
16059            .cocomo
16060            .as_ref()
16061            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16062        complexity_alert: 0,
16063        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16064        cov_line_pct: cov_pct_str(
16065            run.summary_totals.coverage_lines_hit,
16066            run.summary_totals.coverage_lines_found,
16067        ),
16068        cov_fn_pct: cov_pct_str(
16069            run.summary_totals.coverage_functions_hit,
16070            run.summary_totals.coverage_functions_found,
16071        ),
16072        cov_branch_pct: cov_pct_str(
16073            run.summary_totals.coverage_branches_hit,
16074            run.summary_totals.coverage_branches_found,
16075        ),
16076        cov_lines_summary: cov_lines_summary_str(
16077            run.summary_totals.coverage_lines_hit,
16078            run.summary_totals.coverage_lines_found,
16079        ),
16080    };
16081
16082    if let Ok(html) = template.render() {
16083        // Inline the brand + watermark logos as data URIs: a file:// page has no
16084        // server to resolve the /images/logo/* routes, so without this the top-left
16085        // logo and the repeated "Oxide" background watermark render as broken images.
16086        let html = inline_offline_logos(&html);
16087        let index_path = run_dir.join("index.html");
16088        if let Err(e) = fs::write(&index_path, html) {
16089            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16090        }
16091    }
16092}
16093
16094/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
16095/// offline `index.html` displays the brand logo and background watermark when
16096/// opened directly from disk (file://), where the `/images/...` routes do not exist.
16097fn inline_offline_logos(html: &str) -> String {
16098    use base64::Engine;
16099    let text_uri = format!(
16100        "data:image/png;base64,{}",
16101        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16102    );
16103    let small_uri = format!(
16104        "data:image/png;base64,{}",
16105        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16106    );
16107    html.replace("/images/logo/logo-text.png", &text_uri)
16108        .replace("/images/logo/small-logo.png", &small_uri)
16109}
16110
16111/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
16112/// then root (old flat layout), for backwards compatibility.
16113fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16114    // New layout: json/scan-config_*.json
16115    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16116        return Some(found);
16117    }
16118    // Old flat layout: scan-config.json or scan-config_*.json at root
16119    find_scan_config_in_dir_flat(dir)
16120}
16121
16122fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16123    let exact = dir.join("scan-config.json");
16124    if exact.exists() {
16125        return Some(exact);
16126    }
16127    fs::read_dir(dir).ok().and_then(|entries| {
16128        entries
16129            .filter_map(std::result::Result::ok)
16130            .find(|e| {
16131                let name = e.file_name();
16132                let name = name.to_string_lossy();
16133                name.starts_with("scan-config") && name.ends_with(".json")
16134            })
16135            .map(|e| e.path())
16136    })
16137}
16138
16139// ── Config export / import ────────────────────────────────────────────────────
16140
16141/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
16142/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
16143#[derive(Deserialize)]
16144struct ExportPdfRequest {
16145    html: String,
16146    #[serde(default)]
16147    filename: Option<String>,
16148}
16149
16150async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16151    let html_content = body.html;
16152    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16153    if html_content.is_empty() {
16154        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16155    }
16156    // Write HTML to a temp file, run headless Chrome PDF export, read result.
16157    let tmp_dir = std::env::temp_dir();
16158    let html_path = tmp_dir.join(format!(
16159        "sloc-export-{}.html",
16160        uuid::Uuid::new_v4().simple()
16161    ));
16162    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16163    if let Err(e) = std::fs::write(&html_path, &html_content) {
16164        return (
16165            StatusCode::INTERNAL_SERVER_ERROR,
16166            format!("Failed to write temp HTML: {e}"),
16167        )
16168            .into_response();
16169    }
16170    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16171    let _ = std::fs::remove_file(&html_path);
16172    if let Err(e) = pdf_result {
16173        let _ = std::fs::remove_file(&pdf_path);
16174        return (
16175            StatusCode::INTERNAL_SERVER_ERROR,
16176            format!("PDF generation failed: {e}"),
16177        )
16178            .into_response();
16179    }
16180    let pdf_bytes = match std::fs::read(&pdf_path) {
16181        Ok(b) => b,
16182        Err(e) => {
16183            let _ = std::fs::remove_file(&pdf_path);
16184            return (
16185                StatusCode::INTERNAL_SERVER_ERROR,
16186                format!("Failed to read PDF: {e}"),
16187            )
16188                .into_response();
16189        }
16190    };
16191    let _ = std::fs::remove_file(&pdf_path);
16192    let safe_name: String = filename
16193        .chars()
16194        .map(|c| {
16195            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16196                c
16197            } else {
16198                '_'
16199            }
16200        })
16201        .collect();
16202    let disposition = format!("attachment; filename=\"{safe_name}\"");
16203    (
16204        [
16205            (header::CONTENT_TYPE, "application/pdf".to_string()),
16206            (header::CONTENT_DISPOSITION, disposition),
16207        ],
16208        pdf_bytes,
16209    )
16210        .into_response()
16211}
16212
16213async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16214    let toml_str = match toml::to_string_pretty(&state.base_config) {
16215        Ok(s) => s,
16216        Err(e) => {
16217            return (
16218                StatusCode::INTERNAL_SERVER_ERROR,
16219                format!("serialization error: {e}"),
16220            )
16221                .into_response();
16222        }
16223    };
16224    (
16225        [
16226            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16227            (
16228                header::CONTENT_DISPOSITION,
16229                "attachment; filename=\".oxide-sloc.toml\"",
16230            ),
16231        ],
16232        toml_str,
16233    )
16234        .into_response()
16235}
16236
16237#[derive(Serialize)]
16238struct OkResponse {
16239    ok: bool,
16240}
16241
16242#[derive(Serialize)]
16243struct SaveProfileResponse {
16244    ok: bool,
16245    id: String,
16246}
16247
16248#[derive(Serialize)]
16249struct ProfileListResponse {
16250    profiles: Vec<ScanProfile>,
16251}
16252
16253#[derive(Serialize)]
16254struct ImportConfigResponse {
16255    ok: bool,
16256    config: sloc_config::AppConfig,
16257}
16258
16259#[derive(Deserialize)]
16260struct ImportConfigBody {
16261    toml: String,
16262}
16263
16264async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16265    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16266        Ok(config) => {
16267            if let Err(e) = config.validate() {
16268                return error::unprocessable_entity(&e.to_string());
16269            }
16270            Json(ImportConfigResponse { ok: true, config }).into_response()
16271        }
16272        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16273    }
16274}
16275
16276// ── Scan profiles API ─────────────────────────────────────────────────────────
16277
16278async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16279    let store = state.scan_profiles.lock().await;
16280    Json(ProfileListResponse {
16281        profiles: store.profiles.clone(),
16282    })
16283}
16284
16285#[derive(Deserialize)]
16286struct SaveScanProfileBody {
16287    name: String,
16288    params: serde_json::Value,
16289}
16290
16291async fn api_save_scan_profile(
16292    State(state): State<AppState>,
16293    Json(body): Json<SaveScanProfileBody>,
16294) -> impl IntoResponse {
16295    if body.name.trim().is_empty() {
16296        return error::bad_request("name must not be empty");
16297    }
16298
16299    let id = uuid::Uuid::new_v4().to_string();
16300    let profile = ScanProfile {
16301        id: id.clone(),
16302        name: body.name.trim().to_string(),
16303        created_at: chrono::Utc::now().to_rfc3339(),
16304        params: body.params,
16305    };
16306
16307    let mut store = state.scan_profiles.lock().await;
16308    store.profiles.push(profile);
16309    if let Err(e) = store.save(&state.scan_profiles_path) {
16310        tracing::warn!("failed to persist scan profiles: {e}");
16311    }
16312    drop(store);
16313
16314    (
16315        StatusCode::CREATED,
16316        Json(SaveProfileResponse { ok: true, id }),
16317    )
16318        .into_response()
16319}
16320
16321async fn api_delete_scan_profile(
16322    State(state): State<AppState>,
16323    AxumPath(id): AxumPath<String>,
16324) -> impl IntoResponse {
16325    let mut store = state.scan_profiles.lock().await;
16326    let before = store.profiles.len();
16327    store.profiles.retain(|p| p.id != id);
16328    if store.profiles.len() == before {
16329        drop(store);
16330        return error::not_found("profile not found");
16331    }
16332    if let Err(e) = store.save(&state.scan_profiles_path) {
16333        tracing::warn!("failed to persist scan profiles: {e}");
16334    }
16335    drop(store);
16336    Json(OkResponse { ok: true }).into_response()
16337}
16338
16339fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16340    let value = raw.unwrap_or("out/web").trim();
16341    let path = if value.is_empty() {
16342        PathBuf::from("out/web")
16343    } else {
16344        PathBuf::from(value)
16345    };
16346
16347    if path.is_absolute() {
16348        path
16349    } else {
16350        workspace_root().join(path)
16351    }
16352}
16353
16354/// Derive the directory that holds remote-repo clones from the output root.
16355fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16356    std::env::var("SLOC_GIT_CLONES_DIR")
16357        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16358}
16359
16360/// Build a deterministic filesystem path for a cloned remote repository.
16361/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16362pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16363    let safe: String = repo_url
16364        .chars()
16365        .map(|c| {
16366            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16367                c
16368            } else {
16369                '_'
16370            }
16371        })
16372        .take(80)
16373        .collect();
16374    clones_dir.join(safe)
16375}
16376
16377/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16378/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16379pub(crate) fn scan_path_to_artifacts(
16380    scan_path: &Path,
16381    base_config: &AppConfig,
16382    label: &str,
16383) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16384    let mut config = base_config.clone();
16385    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16386    label.clone_into(&mut config.reporting.report_title);
16387    let run = analyze(&config, "git", None, None)?;
16388    let html = render_html(&run)?;
16389    let run_id = run.tool.run_id.clone();
16390    let project_label = sanitize_project_label(label);
16391    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16392    let file_stem = {
16393        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16394        if commit.is_empty() {
16395            project_label
16396        } else {
16397            format!("{project_label}_{commit}")
16398        }
16399    };
16400    let (artifacts, _pending_pdf) = persist_run_artifacts(
16401        &run,
16402        &html,
16403        &output_dir,
16404        label,
16405        &file_stem,
16406        RunResultContext::default(),
16407    )?;
16408    Ok((run_id, artifacts, run))
16409}
16410
16411/// Re-spawn background poll tasks for any polling schedules saved to disk.
16412async fn restart_poll_schedules(state: &AppState) {
16413    let store = state.schedules.lock().await;
16414    let poll_schedules: Vec<_> = store
16415        .schedules
16416        .iter()
16417        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16418        .cloned()
16419        .collect();
16420    drop(store);
16421    for schedule in poll_schedules {
16422        let interval = schedule.interval_secs.unwrap_or(300);
16423        let st = state.clone();
16424        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16425    }
16426}
16427
16428/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16429/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16430/// header (no HMAC over the body), so the token is exposed in cleartext unless
16431/// the transport is encrypted. This is only an advisory — TLS may be terminated
16432/// by an upstream reverse proxy, in which case the warning can be ignored.
16433async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16434    if state.tls_enabled {
16435        return;
16436    }
16437    let store = state.schedules.lock().await;
16438    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16439        s.kind == sloc_git::ScanScheduleKind::Webhook
16440            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16441    });
16442    drop(store);
16443    if has_gitlab_webhook {
16444        tracing::warn!(
16445            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16446             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16447             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16448             proxy so the token is not exposed in cleartext."
16449        );
16450    }
16451}
16452
16453fn split_patterns(raw: Option<&str>) -> Vec<String> {
16454    raw.unwrap_or("")
16455        .lines()
16456        .flat_map(|line| line.split(','))
16457        .map(str::trim)
16458        .filter(|part| !part.is_empty())
16459        .map(ToOwned::to_owned)
16460        .collect()
16461}
16462
16463#[must_use]
16464pub fn build_sub_run(
16465    parent: &AnalysisRun,
16466    sub: &sloc_core::SubmoduleSummary,
16467    parent_path: &str,
16468) -> AnalysisRun {
16469    let sub_files: Vec<_> = parent
16470        .per_file_records
16471        .iter()
16472        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16473        .cloned()
16474        .collect();
16475    let mut config = parent.effective_configuration.clone();
16476    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16477
16478    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16479    let mut functions = 0u64;
16480    let mut classes = 0u64;
16481    let mut variables = 0u64;
16482    let mut imports = 0u64;
16483    let mut test_count = 0u64;
16484    let mut test_assertion_count = 0u64;
16485    let mut test_suite_count = 0u64;
16486    let mut mixed_lines_separate = 0u64;
16487    let mut coverage_lines_found = 0u64;
16488    let mut coverage_lines_hit = 0u64;
16489    let mut coverage_functions_found = 0u64;
16490    let mut coverage_functions_hit = 0u64;
16491    let mut coverage_branches_found = 0u64;
16492    let mut coverage_branches_hit = 0u64;
16493    for r in &sub_files {
16494        functions += r.raw_line_categories.functions;
16495        classes += r.raw_line_categories.classes;
16496        variables += r.raw_line_categories.variables;
16497        imports += r.raw_line_categories.imports;
16498        test_count += r.raw_line_categories.test_count;
16499        test_assertion_count += r.raw_line_categories.test_assertion_count;
16500        test_suite_count += r.raw_line_categories.test_suite_count;
16501        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16502        if let Some(cov) = &r.coverage {
16503            coverage_lines_found += u64::from(cov.lines_found);
16504            coverage_lines_hit += u64::from(cov.lines_hit);
16505            coverage_functions_found += u64::from(cov.functions_found);
16506            coverage_functions_hit += u64::from(cov.functions_hit);
16507            coverage_branches_found += u64::from(cov.branches_found);
16508            coverage_branches_hit += u64::from(cov.branches_hit);
16509        }
16510    }
16511
16512    AnalysisRun {
16513        tool: parent.tool.clone(),
16514        environment: parent.environment.clone(),
16515        effective_configuration: config,
16516        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16517        summary_totals: SummaryTotals {
16518            files_considered: sub.files_analyzed,
16519            files_analyzed: sub.files_analyzed,
16520            files_skipped: 0,
16521            total_physical_lines: sub.total_physical_lines,
16522            code_lines: sub.code_lines,
16523            comment_lines: sub.comment_lines,
16524            blank_lines: sub.blank_lines,
16525            mixed_lines_separate,
16526            functions,
16527            classes,
16528            variables,
16529            imports,
16530            test_count,
16531            test_assertion_count,
16532            test_suite_count,
16533            coverage_lines_found,
16534            coverage_lines_hit,
16535            coverage_functions_found,
16536            coverage_functions_hit,
16537            coverage_branches_found,
16538            coverage_branches_hit,
16539            cyclomatic_complexity: 0,
16540            lsloc: None,
16541            ..Default::default()
16542        },
16543        totals_by_language: sub.language_summaries.clone(),
16544        per_file_records: sub_files,
16545        skipped_file_records: vec![],
16546        warnings: vec![],
16547        submodule_summaries: vec![],
16548        git_commit_short: sub.git_commit_short.clone(),
16549        git_commit_long: sub.git_commit_long.clone(),
16550        git_branch: sub.git_branch.clone(),
16551        git_commit_author: sub.git_commit_author.clone(),
16552        git_commit_date: sub.git_commit_date.clone(),
16553        git_tags: None,
16554        git_nearest_tag: None,
16555        git_remote_url: sub.git_remote_url.clone(),
16556        style_summary: None,
16557        cocomo: None,
16558        uloc: 0,
16559        dryness_pct: None,
16560        duplicate_groups: vec![],
16561        duplicates_excluded: 0,
16562    }
16563}
16564
16565#[must_use]
16566pub fn sanitize_project_label(raw: &str) -> String {
16567    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16568    // where `Path` treats '\' as a literal character, not a separator.
16569    let candidate = raw
16570        .split(['/', '\\'])
16571        .rfind(|s| !s.is_empty())
16572        .unwrap_or("project");
16573
16574    let mut value = String::with_capacity(candidate.len());
16575    for ch in candidate.chars() {
16576        if ch.is_ascii_alphanumeric() {
16577            value.push(ch.to_ascii_lowercase());
16578        } else {
16579            value.push('-');
16580        }
16581    }
16582
16583    let compact = value.trim_matches('-').to_string();
16584    if compact.is_empty() {
16585        "project".to_string()
16586    } else {
16587        compact
16588    }
16589}
16590
16591/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16592/// comparisons with non-canonicalized stored paths work correctly.
16593fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16594    let s = path.to_string_lossy();
16595    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16596        return PathBuf::from(format!(r"\\{rest}"));
16597    }
16598    if let Some(rest) = s.strip_prefix(r"\\?\") {
16599        return PathBuf::from(rest);
16600    }
16601    path
16602}
16603
16604/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16605/// commit page URL for the most common hosting platforms.
16606fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16607    let base = if let Some(rest) = remote.strip_prefix("git@") {
16608        let (host, path) = rest.split_once(':')?;
16609        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16610    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16611        remote
16612            .trim_end_matches('/')
16613            .trim_end_matches(".git")
16614            .to_owned()
16615    } else {
16616        return None;
16617    };
16618    let base = base.trim_end_matches('/');
16619    // GitLab uses /-/commit/; everything else uses /commit/
16620    if base.contains("gitlab.com") || base.contains("gitlab.") {
16621        Some(format!("{base}/-/commit/{sha}"))
16622    } else if base.contains("bitbucket.org") {
16623        Some(format!("{base}/commits/{sha}"))
16624    } else {
16625        Some(format!("{base}/commit/{sha}"))
16626    }
16627}
16628
16629/// Convert a git remote URL (https or git@) + branch name into a browser-openable
16630/// branch page URL for the most common hosting platforms.
16631fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16632    let base = if let Some(rest) = remote.strip_prefix("git@") {
16633        let (host, path) = rest.split_once(':')?;
16634        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16635    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16636        remote
16637            .trim_end_matches('/')
16638            .trim_end_matches(".git")
16639            .to_owned()
16640    } else {
16641        return None;
16642    };
16643    let base = base.trim_end_matches('/');
16644    if base.contains("gitlab.com") || base.contains("gitlab.") {
16645        Some(format!("{base}/-/tree/{branch}"))
16646    } else {
16647        Some(format!("{base}/tree/{branch}"))
16648    }
16649}
16650
16651fn display_path(path: &Path) -> String {
16652    let s = path.to_string_lossy();
16653    // Strip Windows extended-length prefix for display only; the underlying
16654    // PathBuf remains unchanged so file operations are unaffected.
16655    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
16656    // \\?\C:\path           →  C:\path          (local drive)
16657    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16658        return format!(r"\\{rest}");
16659    }
16660    if let Some(rest) = s.strip_prefix(r"\\?\") {
16661        return rest.to_owned();
16662    }
16663    s.into_owned()
16664}
16665
16666fn sanitize_path_str(s: &str) -> String {
16667    // Forward-slash variants of the Windows extended-length prefix that appear
16668    // when paths stored as plain strings have been processed through some path
16669    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
16670    if let Some(rest) = s.strip_prefix("//?/UNC/") {
16671        return format!("//{rest}");
16672    }
16673    if let Some(rest) = s.strip_prefix("//?/") {
16674        return rest.to_owned();
16675    }
16676    display_path(Path::new(s))
16677}
16678
16679fn workspace_root() -> PathBuf {
16680    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
16681    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
16682        let p = PathBuf::from(root);
16683        if p.is_dir() {
16684            return p;
16685        }
16686    }
16687
16688    // Current working directory — works for `cargo run` from the project root
16689    // and for scripts/run.sh which cds there first.
16690    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
16691}
16692
16693/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
16694fn make_git_label(repo: &str, ref_name: &str) -> String {
16695    if repo.is_empty() || ref_name.is_empty() {
16696        return String::new();
16697    }
16698    let base = repo
16699        .trim_end_matches('/')
16700        .trim_end_matches(".git")
16701        .rsplit('/')
16702        .next()
16703        .unwrap_or("repo");
16704    let ref_safe: String = ref_name
16705        .chars()
16706        .map(|c| {
16707            if c.is_alphanumeric() || c == '-' || c == '.' {
16708                c
16709            } else {
16710                '_'
16711            }
16712        })
16713        .collect();
16714    format!("{base}_at_{ref_safe}_sloc")
16715}
16716
16717/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
16718fn desktop_dir() -> PathBuf {
16719    if let Ok(profile) = std::env::var("USERPROFILE") {
16720        let p = PathBuf::from(profile).join("Desktop");
16721        if p.exists() {
16722            return p;
16723        }
16724    }
16725    if let Ok(home) = std::env::var("HOME") {
16726        let p = PathBuf::from(home).join("Desktop");
16727        if p.exists() {
16728            return p;
16729        }
16730    }
16731    workspace_root().join("out").join("web")
16732}
16733
16734fn resolve_input_path(raw: &str) -> PathBuf {
16735    let trimmed = raw.trim();
16736    if trimmed.is_empty() {
16737        return workspace_root().join("samples").join("basic");
16738    }
16739
16740    let candidate = PathBuf::from(trimmed);
16741    let resolved = if candidate.is_absolute() {
16742        candidate
16743    } else {
16744        let rooted = workspace_root().join(&candidate);
16745        if rooted.exists() {
16746            rooted
16747        } else {
16748            workspace_root().join(candidate)
16749        }
16750    };
16751
16752    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
16753    // strip that prefix so stored paths and the displayed "Project path" are clean.
16754    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
16755    PathBuf::from(display_path(&canonical))
16756}
16757
16758fn dir_size_bytes(path: &Path) -> u64 {
16759    let mut total = 0u64;
16760    if let Ok(rd) = fs::read_dir(path) {
16761        for entry in rd.filter_map(Result::ok) {
16762            let p = entry.path();
16763            if p.is_file() {
16764                if let Ok(meta) = p.metadata() {
16765                    total += meta.len();
16766                }
16767            } else if p.is_dir() {
16768                total += dir_size_bytes(&p);
16769            }
16770        }
16771    }
16772    total
16773}
16774
16775#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
16776fn format_dir_size(bytes: u64) -> String {
16777    if bytes >= 1_073_741_824 {
16778        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
16779    } else if bytes >= 1_048_576 {
16780        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
16781    } else if bytes >= 1_024 {
16782        format!("{:.0} KB", bytes as f64 / 1_024.0)
16783    } else {
16784        format!("{bytes} B")
16785    }
16786}
16787
16788fn render_submodule_chips(
16789    root: &Path,
16790    submodules: &[(String, std::path::PathBuf)],
16791    out: &mut String,
16792) {
16793    use std::fmt::Write as _;
16794    let count = submodules.len();
16795    out.push_str(r#"<div class="submodule-preview-strip">"#);
16796    write!(
16797        out,
16798        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>"#,
16799        if count == 1 { "" } else { "s" }
16800    )
16801    .ok();
16802    out.push_str(r#"<div class="submodule-preview-chips">"#);
16803    for (sub_name, sub_rel_path) in submodules {
16804        let sub_abs = root.join(sub_rel_path);
16805        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
16806        let mut sub_stats = PreviewStats::default();
16807        let mut sub_rows: Vec<PreviewRow> = Vec::new();
16808        let mut sub_langs: Vec<&'static str> = Vec::new();
16809        let mut sub_budget = PreviewBudget {
16810            shown: 0,
16811            max_entries: 2000,
16812            max_depth: 9,
16813        };
16814        let mut sub_next_id = 1usize;
16815        let _ = collect_preview_rows(
16816            &sub_abs,
16817            &sub_abs,
16818            0,
16819            None,
16820            &mut sub_next_id,
16821            &mut sub_budget,
16822            &mut sub_stats,
16823            &mut sub_rows,
16824            &mut sub_langs,
16825            &[],
16826            &[],
16827        );
16828        let stats_json = format!(
16829            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
16830            sub_stats.directories,
16831            sub_stats.files,
16832            sub_stats.supported,
16833            sub_stats.skipped,
16834            sub_stats.unsupported
16835        );
16836        write!(
16837            out,
16838            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>"#,
16839            escape_html(sub_name),
16840            escape_html(&sub_rel_path.to_string_lossy()),
16841            escape_html(&sub_size),
16842            escape_html(&stats_json),
16843            escape_html(sub_name),
16844            escape_html(&sub_size),
16845        )
16846        .ok();
16847    }
16848    out.push_str(
16849        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
16850    );
16851    out.push_str(r"</div>");
16852}
16853
16854/// Amber caution banner shown when the selected folder spans multiple independent
16855/// git repositories. Each repo is a one-click button that re-selects it as the
16856/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
16857fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
16858    use std::fmt::Write as _;
16859    const MAX_LISTED: usize = 5;
16860    let total = layout.nested_repos.len();
16861
16862    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
16863    if layout.root_is_repo {
16864        write!(
16865            out,
16866            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>",
16867            if total == 1 { "repository" } else { "repositories" }
16868        )
16869        .ok();
16870    } else {
16871        write!(
16872            out,
16873            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>"
16874        )
16875        .ok();
16876    }
16877
16878    out.push_str(r#"<div class="repo-pick-row">"#);
16879    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
16880        let abs = root.join(rel);
16881        let abs_display = display_path(&abs);
16882        let label = rel.to_string_lossy().replace('\\', "/");
16883        write!(
16884            out,
16885            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
16886            escape_html(&abs_display),
16887            escape_html(&label)
16888        )
16889        .ok();
16890    }
16891    if total > MAX_LISTED {
16892        write!(
16893            out,
16894            r#"<span class="repo-pick-more">and {} more</span>"#,
16895            total - MAX_LISTED
16896        )
16897        .ok();
16898    }
16899    out.push_str(r"</div>");
16900
16901    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
16902    out.push_str(r"</div>");
16903}
16904
16905fn render_language_pills_row(languages: &[&str], out: &mut String) {
16906    use std::fmt::Write as _;
16907    if languages.is_empty() {
16908        out.push_str(
16909            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
16910        );
16911        return;
16912    }
16913    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
16914    for language in languages {
16915        if let Some(icon) = language_icon_file(language) {
16916            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();
16917        } else if let Some(svg) = language_inline_svg(language) {
16918            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();
16919        } else {
16920            write!(
16921                out,
16922                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
16923                escape_html(&language.to_ascii_lowercase()),
16924                escape_html(language)
16925            )
16926            .ok();
16927        }
16928    }
16929}
16930
16931#[allow(clippy::too_many_lines)]
16932fn build_preview_html(
16933    root: &Path,
16934    include_patterns: &[String],
16935    exclude_patterns: &[String],
16936) -> Result<String> {
16937    if !root.exists() {
16938        return Ok(format!(
16939            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
16940            escape_html(&display_path(root))
16941        ));
16942    }
16943
16944    let _selected = display_path(root);
16945    let mut stats = PreviewStats::default();
16946    let mut rows = Vec::new();
16947    let mut languages = Vec::new();
16948    let mut budget = PreviewBudget {
16949        shown: 0,
16950        max_entries: 600,
16951        max_depth: 9,
16952    };
16953    let mut next_row_id = 1usize;
16954
16955    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
16956        || root.to_string_lossy().into_owned(),
16957        std::string::ToString::to_string,
16958    );
16959    let root_modified = root
16960        .metadata()
16961        .ok()
16962        .and_then(|meta| meta.modified().ok())
16963        .map_or_else(|| "-".to_string(), format_system_time);
16964
16965    rows.push(PreviewRow {
16966        row_id: 0,
16967        parent_row_id: None,
16968        depth: 0,
16969        name: format!("{root_name}/"),
16970        kind: PreviewKind::Dir,
16971        is_dir: true,
16972        language: None,
16973        modified: root_modified,
16974        type_label: "Directory".to_string(),
16975    });
16976    collect_preview_rows(
16977        root,
16978        root,
16979        0,
16980        Some(0),
16981        &mut next_row_id,
16982        &mut budget,
16983        &mut stats,
16984        &mut rows,
16985        &mut languages,
16986        include_patterns,
16987        exclude_patterns,
16988    )?;
16989
16990    let root_size = format_dir_size(dir_size_bytes(root));
16991
16992    let mut out = String::new();
16993    write!(
16994        out,
16995        r#"<div class="explorer-wrap" data-project-size="{}">"#,
16996        escape_html(&root_size)
16997    )
16998    .ok();
16999    out.push_str(r#"<div class="explorer-toolbar compact">"#);
17000    out.push_str(r#"<div class="explorer-title-group">"#);
17001    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17002    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17003    out.push_str(r"</div></div>");
17004
17005    out.push_str(r#"<div class="scope-stats">"#);
17006    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();
17007    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();
17008    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();
17009    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();
17010    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();
17011    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>"#);
17012    out.push_str(r"</div>");
17013
17014    let submodules = sloc_core::detect_submodules(root);
17015    if !submodules.is_empty() {
17016        render_submodule_chips(root, &submodules, &mut out);
17017    }
17018
17019    let repo_layout = sloc_core::detect_repository_layout(root);
17020    if repo_layout.has_multiple_repos() {
17021        render_multi_repo_warning(root, &repo_layout, &mut out);
17022    }
17023
17024    out.push_str(r#"<div class="scope-info-row">"#);
17025    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17026    render_language_pills_row(&languages, &mut out);
17027    out.push_str(r"</div></div>");
17028    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>"#);
17029    out.push_str(r"</div>");
17030
17031    out.push_str(r#"<div class="file-explorer-shell">"#);
17032    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>"#);
17033    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>"#);
17034    out.push_str(r#"<div class="file-explorer-tree">"#);
17035    for row in rows {
17036        let status_label = row.kind.label();
17037        let lang_attr = row.language.unwrap_or("");
17038        let toggle_html = if row.is_dir {
17039            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17040                .to_string()
17041        } else {
17042            r#"<span class="tree-bullet">•</span>"#.to_string()
17043        };
17044        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();
17045    }
17046    if budget.shown >= budget.max_entries {
17047        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>"#);
17048    }
17049    out.push_str(r"</div></div></div>");
17050
17051    Ok(out)
17052}
17053
17054#[derive(Default)]
17055struct PreviewStats {
17056    directories: usize,
17057    files: usize,
17058    supported: usize,
17059    skipped: usize,
17060    unsupported: usize,
17061}
17062
17063struct PreviewRow {
17064    row_id: usize,
17065    parent_row_id: Option<usize>,
17066    depth: usize,
17067    name: String,
17068    kind: PreviewKind,
17069    is_dir: bool,
17070    language: Option<&'static str>,
17071    modified: String,
17072    type_label: String,
17073}
17074
17075#[derive(Copy, Clone)]
17076enum PreviewKind {
17077    Dir,
17078    Supported,
17079    Skipped,
17080    Unsupported,
17081}
17082
17083impl PreviewKind {
17084    const fn filter_key(self) -> &'static str {
17085        match self {
17086            Self::Dir => "dir",
17087            Self::Supported => "supported",
17088            Self::Skipped => "skipped",
17089            Self::Unsupported => "unsupported",
17090        }
17091    }
17092
17093    const fn label(self) -> &'static str {
17094        match self {
17095            Self::Dir => "dir",
17096            Self::Supported => "supported",
17097            Self::Skipped => "skipped by policy",
17098            Self::Unsupported => "unsupported",
17099        }
17100    }
17101
17102    const fn badge_class(self) -> &'static str {
17103        match self {
17104            Self::Dir => "badge badge-dir",
17105            Self::Supported => "badge badge-scan",
17106            Self::Skipped => "badge badge-skip",
17107            Self::Unsupported => "badge badge-unsupported",
17108        }
17109    }
17110
17111    const fn node_class(self) -> &'static str {
17112        match self {
17113            Self::Dir => "tree-node-dir",
17114            Self::Supported => "tree-node-supported",
17115            Self::Skipped => "tree-node-skipped",
17116            Self::Unsupported => "tree-node-unsupported",
17117        }
17118    }
17119}
17120
17121struct PreviewBudget {
17122    shown: usize,
17123    max_entries: usize,
17124    max_depth: usize,
17125}
17126
17127/// Handle a single directory entry inside `collect_preview_rows`.
17128/// Returns `true` when the entry was handled (caller should `continue`).
17129#[allow(clippy::too_many_arguments)]
17130fn handle_preview_dir_entry(
17131    root: &Path,
17132    path: &Path,
17133    name: &str,
17134    modified: String,
17135    depth: usize,
17136    parent_row_id: Option<usize>,
17137    row_id: usize,
17138    next_row_id: &mut usize,
17139    budget: &mut PreviewBudget,
17140    stats: &mut PreviewStats,
17141    rows: &mut Vec<PreviewRow>,
17142    languages: &mut Vec<&'static str>,
17143    include_patterns: &[String],
17144    exclude_patterns: &[String],
17145) -> Result<()> {
17146    let relative = preview_relative_path(root, path);
17147    if should_skip_preview_directory(&relative, exclude_patterns) {
17148        return Ok(());
17149    }
17150    stats.directories += 1;
17151    rows.push(PreviewRow {
17152        row_id,
17153        parent_row_id,
17154        depth: depth + 1,
17155        name: format!("{name}/"),
17156        kind: PreviewKind::Dir,
17157        is_dir: true,
17158        language: None,
17159        modified,
17160        type_label: "Directory".to_string(),
17161    });
17162    budget.shown += 1;
17163    if !matches!(name, ".git" | "node_modules" | "target") {
17164        collect_preview_rows(
17165            root,
17166            path,
17167            depth + 1,
17168            Some(row_id),
17169            next_row_id,
17170            budget,
17171            stats,
17172            rows,
17173            languages,
17174            include_patterns,
17175            exclude_patterns,
17176        )?;
17177    }
17178    Ok(())
17179}
17180
17181/// Handle a single file entry inside `collect_preview_rows`.
17182#[allow(clippy::too_many_arguments)]
17183fn handle_preview_file_entry(
17184    root: &Path,
17185    path: &Path,
17186    name: &str,
17187    modified: String,
17188    depth: usize,
17189    parent_row_id: Option<usize>,
17190    row_id: usize,
17191    budget: &mut PreviewBudget,
17192    stats: &mut PreviewStats,
17193    rows: &mut Vec<PreviewRow>,
17194    languages: &mut Vec<&'static str>,
17195    include_patterns: &[String],
17196    exclude_patterns: &[String],
17197) {
17198    let relative = preview_relative_path(root, path);
17199    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17200        return;
17201    }
17202    stats.files += 1;
17203    let kind = classify_preview_file(name);
17204    match kind {
17205        PreviewKind::Supported => stats.supported += 1,
17206        PreviewKind::Skipped => stats.skipped += 1,
17207        PreviewKind::Unsupported => stats.unsupported += 1,
17208        PreviewKind::Dir => {}
17209    }
17210    let language = detect_language_name(name);
17211    if let Some(lang) = language {
17212        if !languages.contains(&lang) {
17213            languages.push(lang);
17214        }
17215    }
17216    rows.push(PreviewRow {
17217        row_id,
17218        parent_row_id,
17219        depth: depth + 1,
17220        name: name.to_owned(),
17221        kind,
17222        is_dir: false,
17223        language,
17224        modified,
17225        type_label: preview_type_label(name, language, kind),
17226    });
17227    budget.shown += 1;
17228}
17229
17230#[allow(clippy::too_many_arguments)]
17231#[allow(clippy::too_many_lines)]
17232fn collect_preview_rows(
17233    root: &Path,
17234    dir: &Path,
17235    depth: usize,
17236    parent_row_id: Option<usize>,
17237    next_row_id: &mut usize,
17238    budget: &mut PreviewBudget,
17239    stats: &mut PreviewStats,
17240    rows: &mut Vec<PreviewRow>,
17241    languages: &mut Vec<&'static str>,
17242    include_patterns: &[String],
17243    exclude_patterns: &[String],
17244) -> Result<()> {
17245    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17246        return Ok(());
17247    }
17248
17249    let mut entries = fs::read_dir(dir)
17250        .with_context(|| format!("failed to read directory {}", dir.display()))?
17251        .filter_map(std::result::Result::ok)
17252        .collect::<Vec<_>>();
17253    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17254
17255    for entry in entries {
17256        if budget.shown >= budget.max_entries {
17257            break;
17258        }
17259
17260        let path = entry.path();
17261        let name = entry.file_name().to_string_lossy().into_owned();
17262        let Ok(metadata) = entry.metadata() else {
17263            continue;
17264        };
17265        let row_id = *next_row_id;
17266        *next_row_id += 1;
17267        let modified = metadata
17268            .modified()
17269            .ok()
17270            .map_or_else(|| "-".to_string(), format_system_time);
17271
17272        if metadata.is_dir() {
17273            handle_preview_dir_entry(
17274                root,
17275                &path,
17276                &name,
17277                modified,
17278                depth,
17279                parent_row_id,
17280                row_id,
17281                next_row_id,
17282                budget,
17283                stats,
17284                rows,
17285                languages,
17286                include_patterns,
17287                exclude_patterns,
17288            )?;
17289            continue;
17290        }
17291
17292        if metadata.is_file() {
17293            handle_preview_file_entry(
17294                root,
17295                &path,
17296                &name,
17297                modified,
17298                depth,
17299                parent_row_id,
17300                row_id,
17301                budget,
17302                stats,
17303                rows,
17304                languages,
17305                include_patterns,
17306                exclude_patterns,
17307            );
17308        }
17309    }
17310
17311    Ok(())
17312}
17313
17314fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17315    if let Some(language) = language {
17316        return format!("{language} source");
17317    }
17318    let lower = name.to_ascii_lowercase();
17319    let ext = Path::new(&lower)
17320        .extension()
17321        .and_then(|e| e.to_str())
17322        .unwrap_or("");
17323    match kind {
17324        PreviewKind::Skipped => {
17325            if lower.ends_with(".min.js") {
17326                "Minified asset".to_string()
17327            } else if [
17328                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17329            ]
17330            .contains(&ext)
17331            {
17332                "Binary or archive".to_string()
17333            } else {
17334                "Skipped file".to_string()
17335            }
17336        }
17337        PreviewKind::Unsupported => {
17338            if ext.is_empty() {
17339                "Unsupported file".to_string()
17340            } else {
17341                format!("{} file", ext.to_ascii_uppercase())
17342            }
17343        }
17344        PreviewKind::Supported => "Supported source".to_string(),
17345        PreviewKind::Dir => "Directory".to_string(),
17346    }
17347}
17348
17349fn format_system_time(time: SystemTime) -> String {
17350    #[allow(clippy::cast_possible_wrap)]
17351    let secs = match time.duration_since(UNIX_EPOCH) {
17352        Ok(duration) => duration.as_secs() as i64,
17353        Err(_) => return "-".to_string(),
17354    };
17355    let days = secs.div_euclid(86_400);
17356    let secs_of_day = secs.rem_euclid(86_400);
17357    let (year, month, day) = civil_from_days(days);
17358    let hour = secs_of_day / 3_600;
17359    let minute = (secs_of_day % 3_600) / 60;
17360    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17361}
17362
17363#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17364fn civil_from_days(days: i64) -> (i32, u32, u32) {
17365    let z = days + 719_468;
17366    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17367    let doe = z - era * 146_097;
17368    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17369    let y = yoe + era * 400;
17370    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17371    let mp = (5 * doy + 2) / 153;
17372    let d = doy - (153 * mp + 2) / 5 + 1;
17373    let m = mp + if mp < 10 { 3 } else { -9 };
17374    let year = y + i64::from(m <= 2);
17375    (year as i32, m as u32, d as u32)
17376}
17377
17378// The input is already lowercased via `to_ascii_lowercase()` before calling
17379// `ends_with`, so the comparisons are inherently case-insensitive.
17380#[allow(clippy::case_sensitive_file_extension_comparisons)]
17381fn detect_language_name(name: &str) -> Option<&'static str> {
17382    let lower = name.to_ascii_lowercase();
17383    if lower.ends_with(".c") || lower.ends_with(".h") {
17384        Some("C")
17385    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17386        .iter()
17387        .any(|s| lower.ends_with(s))
17388    {
17389        Some("C++")
17390    } else if lower.ends_with(".cs") {
17391        Some("C#")
17392    } else if lower.ends_with(".py") {
17393        Some("Python")
17394    } else if lower.ends_with(".sh") {
17395        Some("Shell")
17396    } else if [".ps1", ".psm1", ".psd1"]
17397        .iter()
17398        .any(|s| lower.ends_with(s))
17399    {
17400        Some("PowerShell")
17401    } else {
17402        None
17403    }
17404}
17405
17406fn language_icon_file(language: &str) -> Option<&'static str> {
17407    match language {
17408        "C" => Some("c.png"),
17409        "C++" => Some("cpp.png"),
17410        "C#" => Some("c-sharp.png"),
17411        "Python" => Some("python.png"),
17412        "Shell" => Some("shell.png"),
17413        "PowerShell" => Some("powershell.png"),
17414        "JavaScript" => Some("java-script.png"),
17415        "HTML" => Some("html-5.png"),
17416        "Java" => Some("java.png"),
17417        "Visual Basic" => Some("visual-basic.png"),
17418        "Assembly" => Some("asm.png"),
17419        "Go" => Some("go.png"),
17420        "R" => Some("r.png"),
17421        "XML" => Some("xml.png"),
17422        "Groovy" => Some("groovy.png"),
17423        "Dockerfile" => Some("docker.png"),
17424        "Makefile" => Some("makefile.svg"),
17425        "Perl" => Some("perl.svg"),
17426        _ => None,
17427    }
17428}
17429
17430// Inline SVG badges for languages that have no PNG icon in images/icons/.
17431// Using inline SVG keeps the web UI fully self-contained — no extra files
17432// needed on disk, no 404s on air-gapped deployments.
17433// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17434fn language_inline_svg(language: &str) -> Option<&'static str> {
17435    match language {
17436        "Rust" => Some(
17437            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>"##,
17438        ),
17439        "TypeScript" => Some(
17440            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>"##,
17441        ),
17442        _ => None,
17443    }
17444}
17445
17446// The input is already lowercased via `to_ascii_lowercase()` before the
17447// `ends_with` calls, so these comparisons are inherently case-insensitive.
17448#[allow(clippy::case_sensitive_file_extension_comparisons)]
17449fn classify_preview_file(name: &str) -> PreviewKind {
17450    let lower = name.to_ascii_lowercase();
17451
17452    let scannable = [
17453        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17454        ".psm1", ".psd1",
17455    ]
17456    .iter()
17457    .any(|suffix| lower.ends_with(suffix));
17458
17459    if scannable {
17460        PreviewKind::Supported
17461    } else if lower.ends_with(".min.js")
17462        || lower.ends_with(".lock")
17463        || lower.ends_with(".png")
17464        || lower.ends_with(".jpg")
17465        || lower.ends_with(".jpeg")
17466        || lower.ends_with(".gif")
17467        || lower.ends_with(".zip")
17468        || lower.ends_with(".pdf")
17469        || lower.ends_with(".pyc")
17470        || lower.ends_with(".xz")
17471        || lower.ends_with(".tar")
17472        || lower.ends_with(".gz")
17473    {
17474        PreviewKind::Skipped
17475    } else {
17476        PreviewKind::Unsupported
17477    }
17478}
17479
17480fn preview_relative_path(root: &Path, path: &Path) -> String {
17481    path.strip_prefix(root)
17482        .ok()
17483        .unwrap_or(path)
17484        .to_string_lossy()
17485        .replace('\\', "/")
17486        .trim_matches('/')
17487        .to_string()
17488}
17489
17490fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17491    if relative.is_empty() {
17492        return false;
17493    }
17494
17495    exclude_patterns.iter().any(|pattern| {
17496        wildcard_match(pattern, relative)
17497            || wildcard_match(pattern, &format!("{relative}/"))
17498            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17499    })
17500}
17501
17502fn should_include_preview_file(
17503    relative: &str,
17504    include_patterns: &[String],
17505    exclude_patterns: &[String],
17506) -> bool {
17507    if relative.is_empty() {
17508        return true;
17509    }
17510
17511    let included = include_patterns.is_empty()
17512        || include_patterns
17513            .iter()
17514            .any(|pattern| wildcard_match(pattern, relative));
17515    let excluded = exclude_patterns
17516        .iter()
17517        .any(|pattern| wildcard_match(pattern, relative));
17518
17519    included && !excluded
17520}
17521
17522fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17523    let pattern = pattern.trim().replace('\\', "/");
17524    let candidate = candidate.trim().replace('\\', "/");
17525    let p = pattern.as_bytes();
17526    let c = candidate.as_bytes();
17527    let mut pi = 0usize;
17528    let mut ci = 0usize;
17529    let mut star: Option<usize> = None;
17530    let mut star_match = 0usize;
17531
17532    while ci < c.len() {
17533        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17534            pi += 1;
17535            ci += 1;
17536        } else if pi < p.len() && p[pi] == b'*' {
17537            while pi < p.len() && p[pi] == b'*' {
17538                pi += 1;
17539            }
17540            star = Some(pi);
17541            star_match = ci;
17542        } else if let Some(star_pi) = star {
17543            star_match += 1;
17544            ci = star_match;
17545            pi = star_pi;
17546        } else {
17547            return false;
17548        }
17549    }
17550
17551    while pi < p.len() && p[pi] == b'*' {
17552        pi += 1;
17553    }
17554
17555    pi == p.len()
17556}
17557
17558fn escape_html(value: &str) -> String {
17559    value
17560        .replace('&', "&amp;")
17561        .replace('<', "&lt;")
17562        .replace('>', "&gt;")
17563        .replace('"', "&quot;")
17564        .replace('\'', "&#39;")
17565}
17566
17567#[derive(Clone)]
17568struct SubmoduleRow {
17569    name: String,
17570    relative_path: String,
17571    files_analyzed: u64,
17572    code_lines: u64,
17573    comment_lines: u64,
17574    blank_lines: u64,
17575    total_physical_lines: u64,
17576    html_url: Option<String>,
17577}
17578
17579#[derive(Template)]
17580#[template(
17581    source = r##"
17582<!doctype html>
17583<html lang="en">
17584<head>
17585  <meta charset="utf-8">
17586  <title>OxideSLOC | tmp-sloc</title>
17587  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17588  <style nonce="{{ csp_nonce }}">
17589    :root {
17590      --bg: #efe9e2;
17591      --surface: #fcfaf7;
17592      --surface-2: #f7f0e8;
17593      --surface-3: #efe3d5;
17594      --line: #dfcfbf;
17595      --line-strong: #cfb29c;
17596      --text: #2f241c;
17597      --muted: #6f6257;
17598      --muted-2: #917f71;
17599      --nav: #b85d33;
17600      --nav-2: #7a371b;
17601      --accent: #2563eb;
17602      --accent-2: #1d4ed8;
17603      --oxide: #b85d33;
17604      --oxide-2: #8f4220;
17605      --success-bg: #eaf9ee;
17606      --success-text: #1c8746;
17607      --warn-bg: #fff2d8;
17608      --warn-text: #926000;
17609      --danger-bg: #fdeaea;
17610      --danger-text: #b33b3b;
17611      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17612      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17613      --radius: 14px;
17614    }
17615
17616    body.dark-theme {
17617      --bg: #1b1511;
17618      --surface: #261c17;
17619      --surface-2: #2d221d;
17620      --surface-3: #372922;
17621      --line: #524238;
17622      --line-strong: #6c5649;
17623      --text: #f5ece6;
17624      --muted: #c7b7aa;
17625      --muted-2: #aa9485;
17626      --nav: #b85d33;
17627      --nav-2: #7a371b;
17628      --accent: #6f9bff;
17629      --accent-2: #4a78ee;
17630      --oxide: #d37a4c;
17631      --oxide-2: #b35428;
17632      --success-bg: #163927;
17633      --success-text: #8fe2a8;
17634      --warn-bg: #3c2d11;
17635      --warn-text: #f3cb75;
17636      --danger-bg: #3d1f1f;
17637      --danger-text: #ff9f9f;
17638      --shadow: 0 14px 28px rgba(0,0,0,0.28);
17639      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17640    }
17641
17642    * { box-sizing: border-box; }
17643    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); }
17644    html { overflow-y: scroll; }
17645    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17646    .top-nav, .page, .loading { position: relative; z-index: 2; }
17647    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17648    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17649    .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); }
17650    .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; }
17651    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17652    .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)); }
17653    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17654    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17655    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17656    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17657    .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; }
17658    .nav-project-pill.visible { display:inline-flex; }
17659    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17660    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17661    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17662    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17663    @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; } }
17664    .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; }
17665    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
17666    .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; }
17667    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
17668    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
17669    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
17670    .theme-toggle .icon-sun { display:none; }
17671    body.dark-theme .theme-toggle .icon-sun { display:block; }
17672    body.dark-theme .theme-toggle .icon-moon { display:none; }
17673    .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;}
17674    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17675    .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);}
17676    .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;}
17677    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17678    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17679    .settings-modal-body{padding:14px 16px 16px;}
17680    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17681    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17682    .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;}
17683    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17684    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17685    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17686    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17687    .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;}
17688    .tz-select:focus{border-color:var(--oxide);}
17689    .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; }
17690    .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;}
17691    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
17692    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
17693    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
17694    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
17695    .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; }
17696    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
17697    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
17698    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
17699    .wb-stats-header { padding: 10px 24px 0; }
17700    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
17701    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
17702    .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; }
17703    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17704    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17705    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17706    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
17707    .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; }
17708    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
17709    .ws-stat-analyzers { position: relative; }
17710    .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; }
17711    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
17712    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
17713    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
17714    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
17715    .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; }
17716    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
17717    .ws-divider { display: none; }
17718    .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%; }
17719    .ws-path-link:hover { color:var(--oxide); }
17720    body.dark-theme .ws-path-link { color:var(--oxide); }
17721    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
17722    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17723    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
17724    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
17725    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
17726    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
17727    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
17728    .ws-mini-box-lg { flex:2 1 0; }
17729    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
17730    .ws-mini-box-br { flex:1.5 1 0; }
17731    .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; }
17732    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
17733    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
17734    #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; }
17735    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
17736    .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; }
17737    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
17738    .git-source-banner strong { font-weight:800; color:var(--text); }
17739    .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; }
17740    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
17741    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
17742    .git-source-banner a:hover { text-decoration:underline; }
17743    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
17744    .path-scope-sep { background:var(--line); margin:4px 14px; }
17745    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
17746    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
17747    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
17748    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
17749    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
17750    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
17751    .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; }
17752    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
17753    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
17754    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
17755    .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; }
17756    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
17757    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
17758    [data-wb-tip] { cursor:help; }
17759    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
17760    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
17761    .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; }
17762    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
17763    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
17764    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
17765    .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; }
17766    .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); }
17767    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
17768    .side-info-card { padding: 18px; }
17769    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
17770    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
17771    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
17772    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
17773    .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); }
17774    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
17775    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17776    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
17777    .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; }
17778    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
17779    .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; }
17780    .side-stack::-webkit-scrollbar { display: none; }
17781    .step-nav { padding: 20px 16px; }
17782    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
17783    .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; }
17784    .step-button:hover { background: var(--surface-2); }
17785    .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); }
17786    .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; }
17787    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
17788    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
17789    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
17790    .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); }
17791    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
17792    .step-nav-sum-row:last-child { border-bottom:none; }
17793    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
17794    .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; }
17795    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
17796    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
17797    .quick-scan-section { padding: 10px 4px 14px; }
17798    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
17799    .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; }
17800    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
17801    .quick-scan-btn:active { transform:translateY(0); }
17802    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
17803    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
17804    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
17805    @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);} }
17806    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
17807    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
17808    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
17809    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
17810    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
17811    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
17812    .step-button.done .step-check { opacity:1; }
17813    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
17814    .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; }
17815    .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; }
17816    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
17817    .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; }
17818    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
17819    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
17820    .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; }
17821    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
17822    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
17823    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
17824    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
17825    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
17826    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
17827    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
17828    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
17829    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
17830    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
17831    .card-body { padding: 22px; }
17832    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
17833    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
17834    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
17835    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
17836    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
17837    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
17838    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
17839    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
17840    .field { min-width:0; }
17841    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
17842    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; }
17843    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); }
17844    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
17845    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); }
17846    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17847    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
17848    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
17849    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
17850    .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; }
17851    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
17852    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
17853    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
17854    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
17855    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
17856    .input-group.compact { grid-template-columns: 1fr auto auto; }
17857    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
17858    .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)); }
17859    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
17860    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
17861    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
17862    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
17863    .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; }
17864    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
17865    .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; }
17866    .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); }
17867    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
17868    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
17869    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
17870    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
17871    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
17872    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
17873    @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; } }
17874    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
17875    button.secondary { background: var(--surface); }
17876    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17877    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
17878    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
17879    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
17880    .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); }
17881    .section + .wizard-actions { border-top: none; padding-top: 0; }
17882    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
17883    .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; }
17884    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
17885    .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; }
17886    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
17887    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
17888    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
17889    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
17890    .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); }
17891    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
17892    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
17893    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
17894    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17895    .field-help-grid.coupled-help { margin-top: 12px; }
17896    .field-help-grid.preset-grid { align-items: start; }
17897    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
17898    .preset-inline-row .field { margin: 0; }
17899    .preset-inline-row .explainer-card { margin: 0; }
17900    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
17901    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
17902    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
17903    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
17904    .preset-kv-row > :last-child { flex:1; min-width:0; }
17905    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
17906    .output-field-row .field { margin: 0; }
17907    .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; }
17908    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
17909    .step3-subtitle { margin-bottom: 10px; max-width: none; }
17910    .counting-intro { margin-bottom: 8px; max-width: none; }
17911    .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; }
17912    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
17913    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
17914    .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; }
17915    .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; }
17916    .section-spacer-top { margin-top: 28px; }
17917    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
17918    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
17919    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
17920    .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); }
17921    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
17922    .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; }
17923    .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; }
17924    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
17925    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
17926    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
17927    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
17928    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
17929    .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; }
17930    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
17931    .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); }
17932    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; }
17933    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; }
17934    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
17935    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
17936    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
17937    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
17938    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
17939    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
17940    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
17941    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
17942    .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); }
17943    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
17944    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
17945    .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; }
17946    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
17947    .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; }
17948    .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; }
17949    .always-tracked-tip-body { flex:1; min-width:0; }
17950    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
17951    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
17952    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
17953    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
17954    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
17955    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
17956    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
17957    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
17958    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
17959    .advanced-rule-description strong { color: var(--text); }
17960    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
17961    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
17962    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
17963    .review-link:hover { text-decoration: underline; }
17964    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
17965    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
17966    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
17967    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
17968    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
17969    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
17970    .review-card ul { padding-left: 18px; margin: 0; }
17971    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
17972    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
17973    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
17974    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
17975    .review-card { min-height: 0; }
17976    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
17977    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
17978    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
17979    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
17980    .lang-overflow-chip { position:relative; cursor:default; }
17981    .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; }
17982    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
17983    .git-inline-row { align-items:start; }
17984    .mixed-line-card { display:flex; flex-direction:column; }
17985    .preset-inline-row .toggle-card { justify-content: center; }
17986        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
17987    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
17988    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
17989    .explorer-title { font-size: 18px; font-weight: 850; }
17990    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
17991    .explorer-subtitle.wide { max-width: none; }
17992    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
17993    .better-spacing { align-items:flex-start; justify-content:flex-end; }
17994    .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; }
17995    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
17996    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
17997    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
17998    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
17999    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18000    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18001    .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; }
18002    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18003    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18004    .scope-stat-button.supported { background: var(--success-bg); }
18005    .scope-stat-button.skipped { background: var(--warn-bg); }
18006    .scope-stat-button.unsupported { background: var(--danger-bg); }
18007    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18008    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18009    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18010    [data-tooltip] { position: relative; }
18011    [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); }
18012    [data-tooltip]:hover::after { display: block; }
18013    .scope-stat-button[data-tooltip] { cursor: pointer; }
18014    .badge[data-tooltip] { cursor: help; }
18015    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18016    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18017    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18018    .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; }
18019    .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; }
18020    code { display:inline-block; margin-top:0; padding:2px 7px; }
18021    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18022    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18023    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18024    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18025    .language-pill.muted-pill { color: var(--muted); }
18026    button.language-pill { appearance:none; cursor:pointer; }
18027    .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); }
18028    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18029    .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; }
18030    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18031    .file-explorer-search-row { margin-left: auto; }
18032    .explorer-filter-select { min-width: 170px; width: 170px; }
18033    .explorer-search { min-width: 300px; width: 300px; }
18034    .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); }
18035    .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; }
18036    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18037    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18038    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18039    .file-explorer-tree { max-height: 640px; overflow:auto; }
18040    .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); }
18041    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18042    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18043    .tree-row.hidden-by-filter { display:none !important; }
18044    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18045    .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; }
18046    .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; }
18047    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18048    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18049    .tree-node { display:inline-flex; align-items:center; min-width:0; }
18050    .tree-node-dir { color: var(--text); font-weight: 800; }
18051    .tree-node-supported { color: var(--success-text); }
18052    .tree-node-skipped { color: var(--warn-text); }
18053    .tree-node-unsupported { color: var(--danger-text); }
18054    .tree-node-more { color: var(--muted-2); font-style: italic; }
18055    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18056    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18057    .tree-status-cell { display:flex; justify-content:flex-start; }
18058    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18059    .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; }
18060    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18061    .preview-warning p { margin: 0 0 10px; }
18062    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18063    .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; }
18064    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18065    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18066    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18067    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18068    .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; }
18069    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18070    .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; }
18071    @keyframes prevSpin { to { transform:rotate(360deg); } }
18072    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18073    .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; }
18074    .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; }
18075    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18076    .preview-gate-info svg { width:16px; height:16px; }
18077    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18078    @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); } }
18079    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18080    .preview-loading-text { flex:1; min-width:0; }
18081    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18082    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18083    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18084    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18085    .cov-scan-idle { display:none; }
18086    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18087    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18088    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18089    .cov-scan-title { font-weight:600; font-size:12.5px; }
18090    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18091    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18092    .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; }
18093    .cov-scan-use:hover { opacity:.75; }
18094    .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; }
18095    .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; }
18096    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18097    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18098    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18099    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18100    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18101    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18102    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18103    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18104    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18105    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18106    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18107    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18108    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18109    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18110    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18111    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18112    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18113    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18114    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18115    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18116    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18117    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18118    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18119    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18120    .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); }
18121    .loading.active { display:flex; }
18122    /* Lock page scroll while the analysis modal is open so the removed scrollbar
18123       gutter doesn't pull the centered card slightly left of true center. */
18124    body.modal-open { overflow: hidden; }
18125    .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; }
18126    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18127    .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; }
18128    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18129    .loading-card > * { position:relative; z-index:1; }
18130    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18131    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%); }
18132    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18133    .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; }
18134    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18135    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18136    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18137    .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; }
18138    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18139    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18140    .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; }
18141    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18142    .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; }
18143    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18144    .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; }
18145    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18146    .lc-step.done { color:var(--muted);opacity:0.55; }
18147    .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; }
18148    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18149    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18150    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18151    .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; }
18152    .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; }
18153    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18154    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18155    .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; }
18156    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18157    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18158    .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; }
18159    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18160    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18161    .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; }
18162    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18163    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18164    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18165    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18166    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18167    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18168    .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; }
18169    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18170    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18171    .hidden { display:none !important; }
18172    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18173    .site-footer a{color:var(--muted);}
18174    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18175    @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; } }
18176    .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;}
18177    @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));}}
18178    .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;}
18179    .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; }
18180    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18181    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18182    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18183    .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; }
18184    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18185    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18186    .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; }
18187    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18188    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18189    .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; }
18190    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18191    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18192    .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; }
18193    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18194    .info-icon-btn:hover { color:var(--text); }
18195    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); }
18196    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18197    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18198    .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;}
18199    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18200    .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;}
18201    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18202    #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);}
18203    #offline-file-banner.show{display:flex;}
18204    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18205    #offline-file-banner .ofb-text{flex:1;}
18206    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18207    #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;}
18208    #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;}
18209    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18210    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18211    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18212    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18213    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18214    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18215    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18216  </style>
18217</head>
18218<body id="page-top">
18219  <div id="offline-file-banner" role="alert">
18220    <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>
18221    <span class="ofb-text">
18222      Charts, images, and navigation require the oxide-sloc server.
18223      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18224      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18225      The metric tables below are fully readable without the server.
18226    </span>
18227    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18228  </div>
18229  <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>
18230  <div class="background-watermarks" aria-hidden="true">
18231    <img src="/images/logo/logo-text.png" alt="" />
18232    <img src="/images/logo/logo-text.png" alt="" />
18233    <img src="/images/logo/logo-text.png" alt="" />
18234    <img src="/images/logo/logo-text.png" alt="" />
18235    <img src="/images/logo/logo-text.png" alt="" />
18236    <img src="/images/logo/logo-text.png" alt="" />
18237    <img src="/images/logo/logo-text.png" alt="" />
18238    <img src="/images/logo/logo-text.png" alt="" />
18239    <img src="/images/logo/logo-text.png" alt="" />
18240    <img src="/images/logo/logo-text.png" alt="" />
18241    <img src="/images/logo/logo-text.png" alt="" />
18242    <img src="/images/logo/logo-text.png" alt="" />
18243    <img src="/images/logo/logo-text.png" alt="" />
18244    <img src="/images/logo/logo-text.png" alt="" />
18245  </div>
18246  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18247  <div class="top-nav">
18248    <div class="top-nav-inner">
18249      <a class="brand" href="/">
18250        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18251        <div class="brand-copy">
18252          <div class="brand-title">OxideSLOC</div>
18253          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18254        </div>
18255      </a>
18256      <div class="nav-project-slot">
18257        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18258          <span class="nav-project-label">Project</span>
18259          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18260        </div>
18261      </div>
18262      <div class="nav-status">
18263        <a class="nav-pill" href="/">Home</a>
18264        <div class="nav-dropdown">
18265          <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>
18266          <div class="nav-dropdown-menu">
18267            <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>
18268          </div>
18269        </div>
18270        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18271        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18272        <div class="nav-dropdown">
18273          <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>
18274          <div class="nav-dropdown-menu">
18275            <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>
18276          </div>
18277        </div>
18278        <div class="server-status-wrap" id="server-status-wrap">
18279          <div class="nav-pill server-online-pill" id="server-status-pill">
18280            <span class="status-dot" id="status-dot"></span>
18281            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18282            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18283          </div>
18284          <div class="server-status-tip">
18285            {% if server_mode %}
18286            OxideSLOC is running in server mode — accessible on your LAN.
18287            {% else %}
18288            OxideSLOC is running locally — only accessible from this machine.
18289            {% endif %}
18290            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18291          </div>
18292        </div>
18293        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18294          <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>
18295        </button>
18296        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18297          <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>
18298          <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>
18299        </button>
18300      </div>
18301    </div>
18302  </div>
18303
18304  <div class="loading" id="loading">
18305    <div class="loading-card" id="loading-card">
18306      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18307      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18308      <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>
18309      <div class="lc-steps" id="lc-steps">
18310        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18311        <div class="lc-step-arrow">›</div>
18312        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18313        <div class="lc-step-arrow">›</div>
18314        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18315        <div class="lc-step-arrow">›</div>
18316        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18317      </div>
18318      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18319      <div class="lc-metrics" id="lc-metrics">
18320        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18321        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18322        <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>
18323        <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>
18324      </div>
18325      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18326      <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>
18327      <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>
18328      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18329      <div class="lc-actions hidden" id="lc-actions">
18330        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18331        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18332      </div>
18333      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18334        <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>
18335        Cancel scan
18336      </button>
18337    </div>
18338  </div>
18339
18340  <div class="page">
18341    <div class="workbench-strip">
18342      <div class="workbench-box wb-stats">
18343        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18344          <span class="wb-stats-title">Analysis session</span>
18345        </div>
18346        <div class="ws-left">
18347          <div class="ws-stat ws-stat-analyzers">
18348            <span class="ws-label">Analyzers</span>
18349            <span class="ws-value">
18350              <span class="ws-badge">60 languages</span>
18351            </span>
18352            <div class="ws-lang-tooltip">
18353              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18354              <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>
18355              <div class="ws-lang-grid">
18356                <span class="ws-lang-item">Assembly</span>
18357                <span class="ws-lang-item">C</span>
18358                <span class="ws-lang-item">C++</span>
18359                <span class="ws-lang-item">C#</span>
18360                <span class="ws-lang-item">Clojure</span>
18361                <span class="ws-lang-item">CSS</span>
18362                <span class="ws-lang-item">Dart</span>
18363                <span class="ws-lang-item">Dockerfile</span>
18364                <span class="ws-lang-item">Elixir</span>
18365                <span class="ws-lang-item">Erlang</span>
18366                <span class="ws-lang-item">F#</span>
18367                <span class="ws-lang-item">Go</span>
18368                <span class="ws-lang-item">Groovy</span>
18369                <span class="ws-lang-item">Haskell</span>
18370                <span class="ws-lang-item">HTML</span>
18371                <span class="ws-lang-item">Java</span>
18372                <span class="ws-lang-item">JavaScript</span>
18373                <span class="ws-lang-item">Julia</span>
18374                <span class="ws-lang-item">Kotlin</span>
18375                <span class="ws-lang-item">Lua</span>
18376                <span class="ws-lang-item">Makefile</span>
18377                <span class="ws-lang-item">Nim</span>
18378                <span class="ws-lang-item">Obj-C</span>
18379                <span class="ws-lang-item">OCaml</span>
18380                <span class="ws-lang-item">Perl</span>
18381                <span class="ws-lang-item">PHP</span>
18382                <span class="ws-lang-item">PowerShell</span>
18383                <span class="ws-lang-item">Python</span>
18384                <span class="ws-lang-item">R</span>
18385                <span class="ws-lang-item">Ruby</span>
18386                <span class="ws-lang-item">Rust</span>
18387                <span class="ws-lang-item">Scala</span>
18388                <span class="ws-lang-item">SCSS</span>
18389                <span class="ws-lang-item">Shell</span>
18390                <span class="ws-lang-item">SQL</span>
18391                <span class="ws-lang-item">Svelte</span>
18392                <span class="ws-lang-item">Swift</span>
18393                <span class="ws-lang-item">TypeScript</span>
18394                <span class="ws-lang-item">Vue</span>
18395                <span class="ws-lang-item">XML</span>
18396                <span class="ws-lang-item">Zig</span>
18397                <span class="ws-lang-item">Solidity</span>
18398                <span class="ws-lang-item">Protobuf</span>
18399                <span class="ws-lang-item">HCL</span>
18400                <span class="ws-lang-item">GraphQL</span>
18401                <span class="ws-lang-item">Ada</span>
18402                <span class="ws-lang-item">VHDL</span>
18403                <span class="ws-lang-item">Verilog</span>
18404                <span class="ws-lang-item">Tcl</span>
18405                <span class="ws-lang-item">Pascal</span>
18406                <span class="ws-lang-item">Visual Basic</span>
18407                <span class="ws-lang-item">Lisp</span>
18408                <span class="ws-lang-item">Fortran</span>
18409                <span class="ws-lang-item">Nix</span>
18410                <span class="ws-lang-item">Crystal</span>
18411                <span class="ws-lang-item">D</span>
18412                <span class="ws-lang-item">GLSL</span>
18413                <span class="ws-lang-item">CMake</span>
18414                <span class="ws-lang-item">Elm</span>
18415                <span class="ws-lang-item">Awk</span>
18416              </div>
18417            </div>
18418          </div>
18419          <div class="ws-divider"></div>
18420          <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>
18421          <div class="ws-divider"></div>
18422          <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.">
18423            <span class="ws-label">Output</span>
18424            <span class="ws-value">
18425              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18426                <span id="ws-output-root">project/sloc</span>
18427              </button>
18428            </span>
18429          </div>
18430        </div>
18431      </div>
18432      <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.">
18433        <div class="ws-history-label">Scan history</div>
18434        <div class="ws-history-inner">
18435          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18436            <div class="ws-mini-label">Scans</div>
18437            <div class="ws-mini-value" id="ws-scan-count">—</div>
18438          </div>
18439          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18440            <div class="ws-mini-label">Last Scan</div>
18441            <div class="ws-mini-value" id="ws-last-scan">—</div>
18442          </div>
18443          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18444            <div class="ws-mini-label">Branch</div>
18445            <div class="ws-mini-value" id="ws-branch">—</div>
18446          </div>
18447        </div>
18448      </div>
18449    </div>
18450
18451    <div class="layout">
18452      <aside class="side-stack">
18453        <section class="step-nav">
18454        <h3>Guided scan setup</h3>
18455        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18456          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18457          Top of page
18458        </a>
18459        <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>
18460        <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>
18461        <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>
18462        <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>
18463
18464        <div class="step-steps-divider"></div>
18465
18466        <div class="step-nav-info" id="step-nav-info">
18467          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18468          <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>
18469        </div>
18470
18471        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18472          <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>
18473          <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>
18474          <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>
18475        </div>
18476
18477        <div class="quick-scan-divider"></div>
18478        <div class="quick-scan-section">
18479          <div class="quick-scan-label">No customization needed?</div>
18480          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18481            <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>
18482            Quick Scan
18483          </button>
18484          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18485        </div>
18486
18487        <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>
18488        <div class="sidebar-scroll-divider"></div>
18489        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18490          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18491          Skip to bottom
18492        </a>
18493        </section>
18494
18495      </aside>
18496
18497      <section class="card">
18498        <div class="card-header">
18499          <div class="card-title-row">
18500            <div>
18501              <h1 class="card-title">Guided scan configuration</h1>
18502              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18503            </div>
18504            <div class="wizard-progress" aria-label="Scan setup progress">
18505              <div class="wizard-progress-top">
18506                <span class="wizard-progress-label">Setup progress</span>
18507                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18508              </div>
18509              <div class="wizard-progress-track">
18510                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18511              </div>
18512            </div>
18513          </div>
18514        </div>
18515        <div class="card-body">
18516          <form method="post" action="/analyze" id="analyze-form">
18517            <div class="wizard-step active" data-step="1">
18518              <div class="section">
18519                <div class="section-kicker">Step 1</div>
18520                <h2>Select project and preview scope</h2>
18521                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18522                <div class="field">
18523                  <label for="path">Project path</label>
18524                  {% if !git_repo.is_empty() %}
18525                  <div class="git-source-banner">
18526                    <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>
18527                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18528                    <a href="/git-browser">← Back to Git Browser</a>
18529                  </div>
18530                  {% endif %}
18531                  <div class="path-scope-grid">
18532                      {% if !git_repo.is_empty() %}
18533                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18534                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18535                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18536                      {% else %}
18537                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18538                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18539                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18540                      {% endif %}
18541                    <div class="path-scope-sep"></div>
18542                    <div class="scope-legend-row">
18543                      <span class="scope-legend-label">Scope legend:</span>
18544                      <span class="scope-legend-badges">
18545                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18546                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18547                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18548                      </span>
18549                    </div>
18550                  </div>
18551                  {% if git_repo.is_empty() %}
18552                  {% if server_mode %}
18553                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18554                    ℹ️ Files are compressed and streamed — no fixed size limit.
18555                  </div>
18556                  {% endif %}
18557                  <div class="path-info-row">
18558                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18559                      <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>
18560                      <span id="project-size-text">Project size: —</span>
18561                    </button>
18562                  </div>
18563                  {% else %}
18564                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18565                  {% endif %}
18566                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18567                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18568                </div>
18569
18570                <div class="scope-preview-divider" aria-hidden="true"></div>
18571
18572                <div id="preview-panel">
18573                  <div class="preview-error">Loading preview...</div>
18574                </div>
18575              </div>
18576
18577              <div class="section" style="margin-top:14px;">
18578                <div class="preset-inline-row git-inline-row">
18579                  <div class="toggle-card" style="margin:0;">
18580                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18581                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18582                    <label class="checkbox">
18583                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18584                      <div>
18585                        <span>Detect and separate git submodules</span>
18586                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18587                      </div>
18588                    </label>
18589                  </div>
18590                  <div class="explainer-card prominent" style="margin:0;">
18591                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18592                    <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>
18593                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18594    path = libs/core
18595    url  = https://github.com/org/core.git
18596
18597[submodule "libs/ui"]
18598    path = libs/ui
18599    url  = https://github.com/org/ui.git</div>
18600                  </div>
18601                </div>
18602              </div>
18603
18604              <div class="section">
18605                <div class="field-grid">
18606                  <div class="field">
18607                    <div class="glob-label-row">
18608                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18609                      <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>
18610                    </div>
18611                    <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>
18612                    <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>
18613                  </div>
18614                  <div class="field">
18615                    <div class="glob-label-row">
18616                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18617                    </div>
18618                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
18619                    <div id="quick-exclude-chips" class="quick-excl-row">
18620                      <span class="quick-excl-label">Quick add:</span>
18621                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18622                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18623                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18624                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18625                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18626                      <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>
18627                    </div>
18628                    <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>
18629                  </div>
18630                </div>
18631                <div class="glob-guidance-grid">
18632                  <div class="glob-guidance-card">
18633                    <strong>How to read them</strong>
18634                    <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>
18635                  </div>
18636                  <div class="glob-guidance-card">
18637                    <strong>Common include examples</strong>
18638                    <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>
18639                  </div>
18640                  <div class="glob-guidance-card">
18641                    <strong>Common exclude examples</strong>
18642                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18643                  </div>
18644                </div>
18645              </div>
18646
18647              <div class="section" style="margin-top:14px;">
18648                <div class="preset-inline-row git-inline-row">
18649                  <div class="toggle-card" style="margin:0;">
18650                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18651                    <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>
18652                    <div class="field" style="margin:0;">
18653                      <div class="input-group compact">
18654                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18655                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18656                      </div>
18657                      <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>
18658                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18659                    </div>
18660                  </div>
18661                  <div class="explainer-card prominent" style="margin:0;">
18662                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18663                    <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>
18664                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
18665lcov --capture --directory . --output-file coverage/lcov.info
18666
18667# C / C++ — llvm-cov (LCOV)
18668llvm-profdata merge -sparse default.profraw -o default.profdata
18669llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
18670
18671# C# — coverlet (Cobertura XML)
18672dotnet test --collect:"XPlat Code Coverage"
18673
18674# Python — pytest-cov (Cobertura XML)
18675pytest --cov --cov-report=xml
18676
18677# Python — coverage.py native JSON
18678coverage run -m pytest && coverage json   # writes coverage.json
18679
18680# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
18681./gradlew jacocoTestReport</div>
18682                  </div>
18683                </div>
18684              </div>
18685
18686              <div class="wizard-actions">
18687                <div class="left"></div>
18688                <div class="right">
18689                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
18690                    <span class="preview-gate-spinner" aria-hidden="true"></span>
18691                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
18692                    <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">
18693                      <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>
18694                    </button>
18695                  </div>
18696                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
18697                </div>
18698              </div>
18699            </div>
18700
18701            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
18702              <div class="default-path-modal">
18703                <h3 id="default-path-title">
18704                  <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>
18705                  Proceed with the default sample test?
18706                </h3>
18707                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
18708                <p>You haven&#39;t selected your own project yet.</p>
18709                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
18710                <div class="default-path-actions">
18711                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
18712                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
18713                </div>
18714              </div>
18715            </div>
18716
18717            <div class="wizard-step" data-step="2">
18718              <div class="section">
18719                <div class="section-kicker">Step 2</div>
18720                <h2>Choose counting behavior</h2>
18721                <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>
18722<div class="subsection-bar">Primary line classification</div>
18723                <div class="preset-kv-row">
18724                  <div class="toggle-card mixed-line-card" style="margin:0;">
18725                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
18726                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
18727                    <select id="mixed_line_policy" name="mixed_line_policy">
18728                      <option value="code_only">Code only</option>
18729                      <option value="code_and_comment">Code and comment</option>
18730                      <option value="comment_only">Comment only</option>
18731                      <option value="separate_mixed_category">Separate mixed category</option>
18732                    </select>
18733                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
18734                  </div>
18735                  <div class="explainer-card prominent" style="margin:0;">
18736                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
18737                    <div class="explainer-body" id="mixed-policy-description"></div>
18738                    <div class="code-sample" id="mixed-policy-example"></div>
18739                  </div>
18740                </div>
18741              </div>
18742
18743              <div class="subsection-bar">Additional scan rules</div>
18744              <div class="scan-rules-grid">
18745                <div class="preset-inline-row">
18746                  <div class="toggle-card" style="margin:0;">
18747                    <div class="field-help-title">Generated files</div>
18748                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
18749                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18750                  </div>
18751                  <div class="explainer-card prominent" style="margin:0;">
18752                    <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>
18753                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
18754# Files matching codegen patterns are excluded:
18755#   *.generated.cs  *.pb.go  *.g.dart</div>
18756                  </div>
18757                </div>
18758                <div class="preset-inline-row">
18759                  <div class="toggle-card" style="margin:0;">
18760                    <div class="field-help-title">Minified files</div>
18761                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
18762                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18763                  </div>
18764                  <div class="explainer-card prominent" style="margin:0;">
18765                    <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>
18766                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
18767# Heuristic: very long lines + low whitespace ratio
18768#   jquery.min.js  bundle.min.css  → skipped</div>
18769                  </div>
18770                </div>
18771                <div class="preset-inline-row">
18772                  <div class="toggle-card" style="margin:0;">
18773                    <div class="field-help-title">Vendor directories</div>
18774                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
18775                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
18776                  </div>
18777                  <div class="explainer-card prominent" style="margin:0;">
18778                    <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>
18779                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
18780# Directories named vendor/ node_modules/ third_party/
18781#   → entire subtree is excluded from totals</div>
18782                  </div>
18783                </div>
18784                <div class="preset-inline-row">
18785                  <div class="toggle-card" style="margin:0;">
18786                    <div class="field-help-title">Lockfiles and manifests</div>
18787                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
18788                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
18789                  </div>
18790                  <div class="explainer-card prominent" style="margin:0;">
18791                    <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>
18792                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
18793# Files like package-lock.json  Cargo.lock  yarn.lock
18794#   → skipped unless this is enabled</div>
18795                  </div>
18796                </div>
18797                <div class="preset-inline-row">
18798                  <div class="toggle-card" style="margin:0;">
18799                    <div class="field-help-title">Binary handling</div>
18800                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
18801                    <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>
18802                  </div>
18803                  <div class="explainer-card prominent" style="margin:0;">
18804                    <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>
18805                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
18806# Detected via long lines + low whitespace heuristic
18807#   .png  .exe  .so  → skipped silently</div>
18808                  </div>
18809                </div>
18810                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
18811                  <div class="toggle-card" style="margin:0;">
18812                    <div class="field-help-title">Python docstrings</div>
18813                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
18814                    <label class="checkbox">
18815                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
18816                      <span>Count as comment-style lines</span>
18817                    </label>
18818                  </div>
18819                  <div class="explainer-card prominent" style="margin:0;">
18820                    <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>
18821                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
18822                  </div>
18823                </div>
18824              </div>
18825              <div class="subsection-bar">IEEE 1045-1992 counting</div>
18826              <div class="scan-rules-grid">
18827                <div class="preset-inline-row">
18828                  <div class="toggle-card" style="margin:0;">
18829                    <div class="field-help-title">Continuation lines</div>
18830                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
18831                    <select name="continuation_line_policy" id="continuation_line_policy">
18832                      <option value="each_physical_line" selected>Each physical line (default)</option>
18833                      <option value="collapse_to_logical">Collapse to logical line</option>
18834                    </select>
18835                  </div>
18836                  <div class="explainer-card prominent" style="margin:0;">
18837                    <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>
18838                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
18839    ((a) &gt; (b) ? (a) : (b))
18840# each_physical_line → 2 SLOC
18841# collapse_to_logical → 1 SLOC</div>
18842                  </div>
18843                </div>
18844                <div class="preset-inline-row">
18845                  <div class="toggle-card" style="margin:0;">
18846                    <div class="field-help-title">Block-comment blanks</div>
18847                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
18848                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
18849                      <option value="count_as_comment" selected>Count as comment (default)</option>
18850                      <option value="count_as_blank">Count as blank</option>
18851                    </select>
18852                  </div>
18853                  <div class="explainer-card prominent" style="margin:0;">
18854                    <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>
18855                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
18856 * Summary line
18857 *              ← blank inside block comment
18858 * Detail line
18859 */
18860# count_as_comment → blank counts toward comments
18861# count_as_blank   → blank counts toward blanks</div>
18862                  </div>
18863                </div>
18864                <div class="preset-inline-row">
18865                  <div class="toggle-card" style="margin:0;">
18866                    <div class="field-help-title">Compiler directives</div>
18867                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
18868                    <select name="count_compiler_directives" id="count_compiler_directives">
18869                      <option value="enabled" selected>Include in code SLOC (default)</option>
18870                      <option value="disabled">Exclude from code SLOC</option>
18871                    </select>
18872                  </div>
18873                  <div class="explainer-card prominent" style="margin:0;">
18874                    <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>
18875                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
18876#define BUF 256     ← compiler directive
18877int main() { … }   ← code
18878# enabled  → 3 code SLOC
18879# disabled → 1 code SLOC + 2 directive lines</div>
18880                  </div>
18881                </div>
18882              </div>
18883
18884              <div class="subsection-bar">Code Style Analysis</div>
18885              <div class="scan-rules-grid">
18886                <div class="preset-inline-row">
18887                  <div class="toggle-card" style="margin:0;">
18888                    <div class="field-help-title">Style analysis</div>
18889                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
18890                    <select name="style_analysis_enabled" id="style_analysis_enabled">
18891                      <option value="enabled" selected>Enabled (default)</option>
18892                      <option value="disabled">Disabled — skip style scoring</option>
18893                    </select>
18894                  </div>
18895                  <div class="explainer-card prominent" style="margin:0;">
18896                    <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>
18897                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
18898# style_analysis_enabled = false  (skip, faster scan)
18899# Disabling removes the Code Style section from the report.</div>
18900                  </div>
18901                </div>
18902                <div class="preset-inline-row">
18903                  <div class="toggle-card" style="margin:0;">
18904                    <div class="field-help-title">Column-width threshold</div>
18905                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
18906                    <select name="style_col_threshold" id="style_col_threshold">
18907                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
18908                      <option value="100">100 columns (Uber Go, Google Java)</option>
18909                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
18910                    </select>
18911                  </div>
18912                  <div class="explainer-card prominent" style="margin:0;">
18913                    <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>
18914                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
18915# style_col_threshold = 100 (Uber Go, Google Java)
18916# style_col_threshold = 120 (Uber Go max, Kotlin)
18917# Files where &lt;= 5% of lines exceed the limit
18918# are counted as "N-col compliant" in the report.</div>
18919                  </div>
18920                </div>
18921                <div class="preset-inline-row">
18922                  <div class="toggle-card" style="margin:0;">
18923                    <div class="field-help-title">Score alert threshold</div>
18924                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
18925                    <select name="style_score_threshold" id="style_score_threshold">
18926                      <option value="0" selected>Off — no threshold (default)</option>
18927                      <option value="40">40% — flag poorly styled files</option>
18928                      <option value="50">50% — flag below-average files</option>
18929                      <option value="60">60% — flag below-good files</option>
18930                      <option value="70">70% — flag below-strong files</option>
18931                    </select>
18932                  </div>
18933                  <div class="explainer-card prominent" style="margin:0;">
18934                    <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>
18935                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
18936# style_score_threshold = 50  (flag files &lt; 50%)
18937# Low-scoring files get a red left-border in the
18938# per-file style breakdown table.</div>
18939                  </div>
18940                </div>
18941              </div>
18942
18943              <div class="always-tracked-tip">
18944                <div class="always-tracked-tip-icon">ℹ</div>
18945                <div class="always-tracked-tip-body">
18946                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
18947                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
18948                  <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>
18949                </div>
18950              </div>
18951
18952              <div class="subsection-bar">Advanced Metrics</div>
18953              <div class="scan-rules-grid">
18954                <div class="preset-inline-row">
18955                  <div class="toggle-card" style="margin:0;">
18956                    <div class="field-help-title">COCOMO mode</div>
18957                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
18958                    <select name="cocomo_mode" id="cocomo_mode">
18959                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
18960                      <option value="semi_detached">Semi-detached — mixed constraints</option>
18961                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
18962                    </select>
18963                  </div>
18964                  <div class="explainer-card prominent" style="margin:0;">
18965                    <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>
18966                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
18967# Semi-detached: Effort = 3.0 × KSLOC^1.12
18968# Embedded:     Effort = 3.6 × KSLOC^1.20
18969# All modes: Schedule = 2.5 × Effort^d</div>
18970                  </div>
18971                </div>
18972                <div class="preset-inline-row">
18973                  <div class="toggle-card" style="margin:0;">
18974                    <div class="field-help-title">Complexity alert</div>
18975                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
18976                    <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;" />
18977                  </div>
18978                  <div class="explainer-card prominent" style="margin:0;">
18979                    <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>
18980                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
18981# 50  = flag any file with &gt; 50 branch points
18982# 100 = flag any file with &gt; 100 branch points
18983# Files above the threshold are highlighted
18984# in the result page metric strip.</div>
18985                  </div>
18986                </div>
18987                <div class="preset-inline-row">
18988                  <div class="toggle-card" style="margin:0;">
18989                    <div class="field-help-title">Git hotspots</div>
18990                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
18991                    <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;" />
18992                  </div>
18993                  <div class="explainer-card prominent" style="margin:0;">
18994                    <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>
18995                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
18996# 30  = last month of activity
18997# 365 = last year
18998# 0   = disable the hotspots table
18999# Adds Commits + Last-changed columns to CSV.</div>
19000                  </div>
19001                </div>
19002                <div class="preset-inline-row">
19003                  <div class="toggle-card" style="margin:0;">
19004                    <div class="field-help-title">Duplicate handling</div>
19005                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19006                    <select name="exclude_duplicates" id="exclude_duplicates">
19007                      <option value="disabled" selected>Detect and report only (default)</option>
19008                      <option value="enabled">Detect and exclude from SLOC totals</option>
19009                    </select>
19010                  </div>
19011                  <div class="explainer-card prominent" style="margin:0;">
19012                    <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>
19013                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19014# detect only   → all 3 counted in SLOC
19015# exclude dupes → 1 counted, 2 excluded
19016# Duplicate groups chip always shows the count.</div>
19017                  </div>
19018                </div>
19019                <div class="always-tracked-tip" style="margin:8px 0 0;">
19020                  <div class="always-tracked-tip-icon">ℹ</div>
19021                  <div class="always-tracked-tip-body">
19022                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
19023                    <div class="always-tracked-metrics-row">
19024                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19025                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
19026                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
19027                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
19028                    </div>
19029                    <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>
19030                  </div>
19031                </div>
19032              </div>
19033
19034              <div class="wizard-actions">
19035                <div class="left">
19036                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19037                </div>
19038                <div class="right">
19039                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19040                </div>
19041              </div>
19042            </div>
19043
19044            <div class="wizard-step" data-step="3">
19045              <div class="section">
19046                <div class="section-kicker">Step 3</div>
19047                <h2>Output and report identity</h2>
19048                <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>
19049                <div class="preset-kv-row">
19050                  <div class="toggle-card" style="margin:0;">
19051                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19052                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19053                    <select id="scan_preset">
19054                      <option value="balanced">Balanced local scan</option>
19055                      <option value="code_focused">Code focused</option>
19056                      <option value="comment_audit">Comment audit</option>
19057                      <option value="deep_review">Deep review</option>
19058                    </select>
19059                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19060                  </div>
19061                  <div class="explainer-card">
19062                    <div class="field-help-title">Selected scan preset</div>
19063                    <div class="explainer-body" id="scan-preset-description"></div>
19064                    <div class="preset-summary-row" id="scan-preset-summary"></div>
19065                    <div class="code-sample" id="scan-preset-example"></div>
19066                    <div class="preset-note" id="scan-preset-note"></div>
19067                  </div>
19068                </div>
19069                <hr class="step3-separator" />
19070                <div class="preset-kv-row">
19071                  <div class="toggle-card" style="margin:0;">
19072                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19073                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19074                    <select id="artifact_preset">
19075                      <option value="review">Review bundle</option>
19076                      <option value="full">Full bundle</option>
19077                      <option value="html_only">HTML only</option>
19078                      <option value="machine">Machine bundle</option>
19079                    </select>
19080                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19081                  </div>
19082                  <div class="explainer-card">
19083                    <div class="field-help-title">Selected artifact preset</div>
19084                    <div class="explainer-body" id="artifact-preset-description"></div>
19085                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
19086                    <div class="code-sample" id="artifact-preset-example"></div>
19087                  </div>
19088                </div>
19089              </div>
19090
19091              <div class="section section-spacer-top">
19092                <div class="output-field-row">
19093                  <div class="field">
19094                    <label for="output_dir">Output directory</label>
19095                    {% if server_mode %}
19096                    <div class="input-group compact">
19097                      <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);" />
19098                    </div>
19099                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19100                    {% else %}
19101                    <div class="input-group compact">
19102                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19103                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19104                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
19105                    </div>
19106                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19107                    {% endif %}
19108                  </div>
19109                  <div class="output-field-aside">
19110                    <strong>Where reports land</strong>
19111                    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.
19112                  </div>
19113                </div>
19114              </div>
19115
19116              <div class="section section-spacer-top">
19117                <div class="output-field-row">
19118                  <div class="field">
19119                    <label for="report_title">Report title</label>
19120                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19121                    <div class="hint">Appears in HTML and PDF output headers.</div>
19122                  </div>
19123                  <div class="output-field-aside">
19124                    <strong>Shown in exported artifacts</strong>
19125                    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.
19126                  </div>
19127                </div>
19128              </div>
19129
19130              <div class="section section-spacer-top">
19131                <div class="output-field-row">
19132                  <div class="field">
19133                    <label for="report_header_footer">Report header / footer</label>
19134                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19135                    <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>
19136                  </div>
19137                  <div class="output-field-aside">
19138                    <strong>Page-level identification</strong>
19139                    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.
19140                  </div>
19141                </div>
19142              </div>
19143
19144              <div class="wizard-actions">
19145                <div class="left">
19146                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19147                </div>
19148                <div class="right">
19149                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19150                </div>
19151              </div>
19152            </div>
19153
19154            <div class="wizard-step" data-step="4">
19155              <div class="section">
19156                <div class="section-kicker">Step 4</div>
19157                <h2>Review selections and run</h2>
19158                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19159                <div class="review-grid">
19160                  <div class="review-card highlight">
19161                    <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>
19162                    <ul id="review-scan-summary"></ul>
19163                  </div>
19164                  <div class="review-card highlight">
19165                    <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>
19166                    <ul id="review-count-summary"></ul>
19167                  </div>
19168                  <div class="review-card">
19169                    <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>
19170                    <ul id="review-artifact-summary"></ul>
19171                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19172                  </div>
19173                  <div class="review-card">
19174                    <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>
19175                    <ul id="review-preview-summary"></ul>
19176                  </div>
19177                </div>
19178              </div>
19179
19180              <div class="wizard-actions">
19181                <div class="left">
19182                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19183                </div>
19184                <div class="right">
19185                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
19186                </div>
19187              </div>
19188            </div>
19189            {% if server_mode %}
19190            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19191            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19192            {% endif %}
19193          </form>
19194        </div>
19195      </section>
19196    </div>
19197  </div>
19198
19199  <script nonce="{{ csp_nonce }}">
19200    (function () {
19201      function startScanPhase() {
19202        var phaseEl = document.getElementById("scan-phase");
19203        if (!phaseEl) return;
19204        var phases = [
19205          "Discovering files...",
19206          "Decoding file encodings...",
19207          "Detecting languages...",
19208          "Analyzing source lines...",
19209          "Applying counting policies...",
19210          "Aggregating results...",
19211          "Rendering report..."
19212        ];
19213        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19214        var i = 0;
19215        function next() {
19216          phaseEl.style.opacity = "0";
19217          setTimeout(function () {
19218            phaseEl.textContent = phases[i];
19219            phaseEl.style.opacity = "0.85";
19220            var delay = durations[i] || 1800;
19221            i++;
19222            if (i < phases.length) { setTimeout(next, delay); }
19223          }, 200);
19224        }
19225        next();
19226      }
19227
19228      var form = document.getElementById("analyze-form");
19229      var loading = document.getElementById("loading");
19230      var submitButton = document.getElementById("submit-button");
19231      var pathInput = document.getElementById("path");
19232      var GIT_MODE = !!(pathInput && pathInput.readOnly);
19233      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19234      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19235      var outputDirInput = document.getElementById("output_dir");
19236      var reportTitleInput = document.getElementById("report_title");
19237      var previewPanel = document.getElementById("preview-panel");
19238      var refreshButton = document.getElementById("refresh-preview");
19239      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19240      var useSamplePath = document.getElementById("use-sample-path");
19241      var useDefaultOutput = document.getElementById("use-default-output");
19242      var browsePath = document.getElementById("browse-path");
19243      var browseOutputDir = document.getElementById("browse-output-dir");
19244      var browseCoverage = document.getElementById("browse-coverage");
19245      var coverageInput = document.getElementById("coverage_file");
19246      var covScanStatus = document.getElementById("cov-scan-status");
19247      var coverageSuggestTimer = null;
19248      var covAutoFilled = false;
19249      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19250
19251      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19252      (function() {
19253        var ids = ["path", "output_dir"];
19254        ids.forEach(function(id) {
19255          var el = document.getElementById(id);
19256          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19257        });
19258      }());
19259      function fmtBytes(b) {
19260        b = Number(b) || 0;
19261        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19262        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19263        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
19264        return b + ' B';
19265      }
19266      var themeToggle = document.getElementById("theme-toggle");
19267
19268      function showBannerToast(msg, isError, opts) {
19269        opts = opts || {};
19270        var t = document.createElement('div');
19271        t.className = isError ? 'toast-error' : 'toast-success';
19272        var topPos = opts.top ? '80px' : null;
19273        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19274          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19275          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19276          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19277        if (opts.icon) {
19278          var inner = document.createElement('span');
19279          inner.innerHTML = opts.icon + ' ';
19280          t.appendChild(inner);
19281        }
19282        t.appendChild(document.createTextNode(msg));
19283        document.body.appendChild(t);
19284        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19285      }
19286      var mixedLinePolicy = document.getElementById("mixed_line_policy");
19287      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19288      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19289      var scanPreset = document.getElementById("scan_preset");
19290      var artifactPreset = document.getElementById("artifact_preset");
19291      var includeGlobsInput = document.getElementById("include_globs");
19292      var excludeGlobsInput = document.getElementById("exclude_globs");
19293
19294      // Include globs scope badge — updates reactively as the user types.
19295      (function() {
19296        var badge = document.getElementById("include-scope-badge");
19297        if (!badge || !includeGlobsInput) return;
19298        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> ';
19299        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> ';
19300        function update() {
19301          var val = includeGlobsInput.value.trim();
19302          if (!val) {
19303            badge.className = "include-scope-badge scope-all";
19304            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19305          } else {
19306            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19307            badge.className = "include-scope-badge scope-narrow";
19308            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19309          }
19310        }
19311        includeGlobsInput.addEventListener("input", update);
19312        update();
19313      }());
19314
19315      // Quick-exclude chips — append pattern to exclude_globs textarea.
19316      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19317        chip.addEventListener("click", function() {
19318          var pattern = chip.getAttribute("data-pattern") || "";
19319          if (!pattern || !excludeGlobsInput) return;
19320          var current = excludeGlobsInput.value.trim();
19321          // For the "skip all" chip, replace any existing dep patterns cleanly.
19322          var patterns = pattern.split("\n");
19323          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19324          var added = false;
19325          patterns.forEach(function(p) {
19326            p = p.trim();
19327            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19328          });
19329          if (added) {
19330            excludeGlobsInput.value = lines.join("\n");
19331            excludeGlobsInput.dispatchEvent(new Event("input"));
19332          }
19333          chip.classList.add("active");
19334        });
19335      });
19336
19337      var liveReportTitle = document.getElementById("live-report-title");
19338      var navProjectPill = document.getElementById("nav-project-pill");
19339      var navProjectTitle = document.getElementById("nav-project-title");
19340      var reportTitlePreview = null;
19341      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19342      var wizardProgressValue = document.getElementById("wizard-progress-value");
19343      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19344      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19345      var reportTitleTouched = false;
19346      var currentStep = 1;
19347      var previewTimer = null;
19348      var _previewGen = 0;
19349      // True while the scope preview (local) / project upload (server mode) is in
19350      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19351      // user can't advance past a project whose scope/upload isn't ready yet.
19352      var previewLoading = false;
19353      // Set when the current preview reports multiple independent git repos under
19354      // the selected root. Advancing past step 1 is blocked until the user ticks
19355      // the acknowledgement checkbox (or re-selects a single repository).
19356      var multiRepoBlocked = false;
19357      function step1ForwardBlocked() {
19358        return previewLoading || multiRepoBlocked;
19359      }
19360      function refreshStep1Gate() {
19361        var nextBtn = document.getElementById("step1-next");
19362        if (nextBtn) {
19363          var blocked = step1ForwardBlocked();
19364          nextBtn.classList.toggle("is-blocked", blocked);
19365          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19366        }
19367      }
19368      function setPreviewLoading(loading) {
19369        previewLoading = !!loading;
19370        var gate = document.getElementById("preview-gate-status");
19371        refreshStep1Gate();
19372        if (gate) {
19373          var txt = gate.querySelector(".preview-gate-text");
19374          if (txt) txt.textContent = SERVER_MODE
19375            ? "Uploading & scanning project…"
19376            : "Scanning project scope…";
19377          gate.style.display = previewLoading ? "flex" : "none";
19378        }
19379      }
19380      // Info button on the gate: scroll up to the live scope preview so the user
19381      // can see exactly what is being scanned (elapsed time + rotating status).
19382      var previewGateInfo = document.getElementById("preview-gate-info");
19383      if (previewGateInfo) {
19384        previewGateInfo.addEventListener("click", function () {
19385          var target = document.getElementById("preview-panel");
19386          if (!target) return;
19387          target.scrollIntoView({ behavior: "smooth", block: "center" });
19388          target.classList.add("preview-panel-flash");
19389          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19390        });
19391      }
19392      var quickScanBtn = document.getElementById("quick-scan-btn");
19393
19394      function dismissAnalysisModal() {
19395        if (loading) loading.classList.remove("active");
19396        document.body.classList.remove("modal-open");
19397        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19398          var el = document.getElementById(id);
19399          if (el) el.classList.add("hidden");
19400        });
19401        var cancelBtn = document.getElementById("lc-cancel-btn");
19402        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19403        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19404        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19405        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19406        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");}
19407        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19408        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19409        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19410        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19411        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19412        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19413      }
19414
19415      var lcDismissBtn = document.getElementById("lc-dismiss");
19416      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19417
19418      // When the browser restores this page from bfcache (Back button after navigating to results),
19419      // the loading overlay would still be showing its active state. Dismiss it immediately.
19420      window.addEventListener("pageshow", function(e) {
19421        if (e.persisted) { dismissAnalysisModal(); }
19422      });
19423
19424      function startAsyncAnalysis(formData) {
19425        var gitRepo = (formData.get("git_repo") || "").toString();
19426        var gitRef  = (formData.get("git_ref")  || "").toString();
19427        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19428        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19429
19430        var pathEl = document.getElementById("lc-path-text");
19431        if (pathEl) pathEl.textContent = displayPath;
19432
19433        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19434          var el = document.getElementById(id);
19435          if (el) el.classList.add("hidden");
19436        });
19437        var cancelBtn = document.getElementById("lc-cancel-btn");
19438        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19439        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19440        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19441        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19442        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19443        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19444        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19445        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");}
19446        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19447
19448        if (loading) loading.classList.add("active");
19449        document.body.classList.add("modal-open");
19450
19451        var startTime = Date.now();
19452        var elapsedTimer = setInterval(function() {
19453          var s = Math.floor((Date.now() - startTime) / 1000);
19454          var el = document.getElementById("lc-elapsed");
19455          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19456        }, 1000);
19457
19458        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19459
19460        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();}
19461
19462        var PHASE_DESC = {
19463          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19464          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19465          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19466          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19467          'Done': 'Analysis complete \u2014 loading your results\u2026',
19468          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19469        };
19470        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19471        function lcSetPhase(txt) {
19472          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19473          var desc = document.getElementById("lc-stage-desc");
19474          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19475          var step = PHASE_STEP[txt] || 1;
19476          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");}
19477        }
19478
19479        function lcShowCancelled() {
19480          clearInterval(elapsedTimer);
19481          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19482          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19483          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19484          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19485          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19486          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19487          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19488          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19489          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19490          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19491        }
19492
19493        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19494        if (lcCancelBtn) {
19495          lcCancelBtn.onclick = function() {
19496            if (!activeWaitId) { dismissAnalysisModal(); return; }
19497            lcCancelBtn.disabled = true;
19498            lcCancelBtn.textContent = "Cancelling\u2026";
19499            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19500              .then(function() { lcShowCancelled(); })
19501              .catch(function() { lcShowCancelled(); });
19502          };
19503        }
19504
19505        function lcShowError(msg) {
19506          clearInterval(elapsedTimer);
19507          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19508          lcSetPhase("Failed");
19509          var msgEl = document.getElementById("lc-err-msg");
19510          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19511          var errEl = document.getElementById("lc-err");
19512          var actEl = document.getElementById("lc-actions");
19513          if (errEl) errEl.classList.remove("hidden");
19514          if (actEl) actEl.classList.remove("hidden");
19515          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19516          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19517        }
19518
19519        function lcPoll(waitId) {
19520          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19521            .then(function(r) {
19522              if (!r.ok) throw new Error("HTTP " + r.status);
19523              return r.json();
19524            })
19525            .then(function(data) {
19526              pollRetries = 0;
19527              if (data.state === "complete") {
19528                clearInterval(elapsedTimer);
19529                lcSetPhase("Done");
19530                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19531              } else if (data.state === "failed") {
19532                lcShowError(data.message);
19533              } else if (data.state === "cancelled") {
19534                lcShowCancelled();
19535              } else {
19536                var s = Math.floor((Date.now() - startTime) / 1000);
19537                if (s > 90 && !warnShown) {
19538                  warnShown = true;
19539                  var w = document.getElementById("lc-warn");
19540                  if (w) w.classList.remove("hidden");
19541                }
19542                lcSetPhase(data.phase || "Running");
19543                var fd = data.files_done || 0, ft = data.files_total || 0;
19544                if (ft > 0) {
19545                  var card = document.getElementById("lc-files-card");
19546                  if (card) card.classList.remove("hidden");
19547                  var el = document.getElementById("lc-files");
19548                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19549                  var now = Date.now();
19550                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19551                  if (fdelta > 0 && tdelta > 0.4) {
19552                    var fps = Math.round(fdelta / tdelta);
19553                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19554                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19555                  }
19556                  lastFd = fd; lastFdTime = now;
19557                }
19558                setTimeout(function() { lcPoll(waitId); }, 1500);
19559              }
19560            })
19561            .catch(function() {
19562              pollRetries++;
19563              if (pollRetries >= 5) {
19564                lcShowError("Lost connection to server. Reload to check status.");
19565              } else {
19566                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19567              }
19568            });
19569        }
19570
19571        var params = new URLSearchParams(formData);
19572        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19573          .then(function(r) {
19574            var waitId = r.headers.get("x-wait-id");
19575            if (!waitId) { window.location.href = "/scan"; return; }
19576            activeWaitId = waitId;
19577            setTimeout(function() { lcPoll(waitId); }, 1500);
19578          })
19579          .catch(function(err) {
19580            lcShowError("Could not reach server: " + (err.message || err));
19581          });
19582      }
19583
19584      if (quickScanBtn) {
19585        quickScanBtn.addEventListener("click", function () {
19586          var pathVal = pathInput ? pathInput.value.trim() : "";
19587          if (!pathVal) {
19588            alert("Please enter or browse to a project path first.");
19589            return;
19590          }
19591          quickScanBtn.disabled = true;
19592          quickScanBtn.textContent = "Scanning...";
19593          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19594          startAsyncAnalysis(new FormData(form));
19595        });
19596      }
19597
19598      var mixedPolicyInfo = {
19599        code_only: {
19600          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.",
19601          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'
19602        },
19603        code_and_comment: {
19604          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.",
19605          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'
19606        },
19607        comment_only: {
19608          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.",
19609          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'
19610        },
19611        separate_mixed_category: {
19612          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.",
19613          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'
19614        }
19615      };
19616
19617      var scanPresetInfo = {
19618        balanced: {
19619          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.",
19620          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19621          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19622          note: "Best when you want a stable local overview before making deeper adjustments.",
19623          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19624        },
19625        code_focused: {
19626          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19627          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19628          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19629          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19630          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19631        },
19632        comment_audit: {
19633          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19634          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19635          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19636          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19637          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19638        },
19639        deep_review: {
19640          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19641          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19642          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19643          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19644          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19645        }
19646      };
19647
19648      var artifactPresetInfo = {
19649        review: {
19650          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19651          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19652          example: "Ideal for a quick local review before sharing results."
19653        },
19654        full: {
19655          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19656          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19657          example: "Use when producing a deliverable or storing a snapshot for future comparison."
19658        },
19659        html_only: {
19660          description: "Standalone HTML report only. No PDF generation, no data files.",
19661          chips: ["HTML only"],
19662          example: "Fastest option when you only need to open the report in a browser."
19663        },
19664        machine: {
19665          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
19666          chips: ["JSON", "CSV", "no HTML", "no PDF"],
19667          example: "Use in CI to capture metrics without generating visual reports."
19668        }
19669      };
19670
19671      function applyArtifactPreset() {
19672        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
19673        if (!info) return;
19674        var descEl = document.getElementById("artifact-preset-description");
19675        var exampleEl = document.getElementById("artifact-preset-example");
19676        if (descEl) descEl.textContent = info.description;
19677        if (exampleEl) exampleEl.textContent = info.example;
19678        renderPresetChips("artifact-preset-summary", info.chips);
19679      }
19680
19681      function applyTheme(theme) {
19682        if (theme === "dark") document.body.classList.add("dark-theme");
19683        else document.body.classList.remove("dark-theme");
19684      }
19685
19686      function loadSavedTheme() {
19687        var saved = null;
19688        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
19689        applyTheme(saved === "dark" ? "dark" : "light");
19690      }
19691
19692      function updateScrollProgress() {
19693        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
19694        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
19695        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
19696        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
19697        var step = Math.min(Math.max(currentStep, 1), 4);
19698        var base = stepBase[step];
19699        var end  = stepEnd[step];
19700
19701        var scrollFrac = 0;
19702        var activePanel = document.querySelector(".wizard-step.active");
19703        if (activePanel) {
19704          var scrollTop = window.scrollY || window.pageYOffset || 0;
19705          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
19706          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
19707          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
19708          var scrolled = scrollTop + viewH - panelTop;
19709          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
19710        }
19711
19712        var percent = Math.round(base + (end - base) * scrollFrac);
19713        percent = Math.min(end, Math.max(base, percent));
19714        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
19715        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
19716      }
19717
19718      function updateWizardProgress() {
19719        updateScrollProgress();
19720      }
19721
19722      var stepDescriptions = [
19723        "Choose a project folder, apply scope filters, and preview which files will be counted.",
19724        "Configure how mixed code-plus-comment lines and docstrings are classified.",
19725        "Pick your output formats, scan preset, and where reports are saved.",
19726        "Review all settings and launch the analysis."
19727      ];
19728
19729      function updateStepNav(step) {
19730        var infoLabel = document.getElementById("step-nav-info-label");
19731        var infoDesc  = document.getElementById("step-nav-info-desc");
19732        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
19733        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
19734      }
19735
19736      function updateSidebarSummary() {
19737        var sumPath    = document.getElementById("sum-path");
19738        var sumPreset  = document.getElementById("sum-preset");
19739        var sumOutput  = document.getElementById("sum-output");
19740        var sidebarSummary = document.getElementById("sidebar-summary");
19741        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
19742        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
19743        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
19744        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
19745        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
19746        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
19747        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
19748      }
19749
19750      function setStep(step, pushHistory) {
19751        currentStep = step;
19752        stepPanels.forEach(function (panel) {
19753          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
19754        });
19755        stepButtons.forEach(function (button) {
19756          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
19757        });
19758        var layoutEl = document.querySelector(".layout");
19759        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
19760        updateWizardProgress();
19761        updateStepNav(step);
19762        stepButtons.forEach(function(btn) {
19763          var t = Number(btn.getAttribute("data-step-target"));
19764          btn.classList.toggle("done", t < step);
19765        });
19766        updateSidebarSummary();
19767
19768        if (pushHistory !== false) {
19769          try {
19770            history.pushState({ wizardStep: step }, "", "#step" + step);
19771          } catch (e) {}
19772        }
19773
19774        window.scrollTo({ top: 0, behavior: "instant" });
19775      }
19776
19777      window.addEventListener("popstate", function (e) {
19778        if (e.state && e.state.wizardStep) {
19779          setStep(e.state.wizardStep, false);
19780        } else {
19781          var hashMatch = location.hash.match(/^#step([1-4])$/);
19782          if (hashMatch) setStep(Number(hashMatch[1]), false);
19783        }
19784      });
19785
19786      function inferTitleFromPath(value) {
19787        if (!value) return "project";
19788        var cleaned = value.replace(/[\/\\]+$/, "");
19789        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
19790        return parts.length ? parts[parts.length - 1] : value;
19791      }
19792
19793      function updateReportTitleFromPath() {
19794        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
19795        if (!reportTitleTouched) {
19796          reportTitleInput.value = inferred;
19797        }
19798        var title = reportTitleInput.value || inferred;
19799        if (liveReportTitle) liveReportTitle.textContent = title;
19800        if (reportTitlePreview) reportTitlePreview.textContent = title;
19801        document.title = "OxideSLOC | " + title;
19802
19803        var projectPath = (pathInput.value || "").trim();
19804        if (navProjectPill && navProjectTitle) {
19805          if (projectPath.length > 0) {
19806            navProjectTitle.textContent = inferred;
19807            navProjectPill.classList.add("visible");
19808          } else {
19809            navProjectTitle.textContent = "";
19810            navProjectPill.classList.remove("visible");
19811          }
19812        }
19813      }
19814
19815      function updateMixedPolicyUI() {
19816        var key = mixedLinePolicy.value || "code_only";
19817        var info = mixedPolicyInfo[key];
19818        document.getElementById("mixed-policy-description").textContent = info.description;
19819        document.getElementById("mixed-policy-example").textContent = info.example;
19820      }
19821
19822      function updatePythonDocstringUI() {
19823        var checked = !!pythonDocstrings.checked;
19824        document.getElementById("python-docstring-example").textContent = checked
19825          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
19826          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
19827        document.getElementById("python-docstring-live-help").textContent = checked
19828          ? "Enabled: docstrings contribute to comment-style totals."
19829          : "Disabled: docstrings are not counted as comment content.";
19830      }
19831
19832      function renderPresetChips(targetId, chips) {
19833        var target = document.getElementById(targetId);
19834        if (!target) return;
19835        target.innerHTML = (chips || []).map(function (chip) {
19836          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
19837        }).join('');
19838      }
19839
19840      function updatePresetDescriptions() {
19841        var scanInfo = scanPresetInfo[scanPreset.value];
19842        if (!scanInfo) return;
19843        document.getElementById("scan-preset-description").textContent = scanInfo.description;
19844        document.getElementById("scan-preset-example").textContent = scanInfo.example;
19845        document.getElementById("scan-preset-note").textContent = scanInfo.note;
19846        renderPresetChips("scan-preset-summary", scanInfo.chips);
19847      }
19848
19849      function applyScanPreset() {
19850        var info = scanPresetInfo[scanPreset.value];
19851        if (!info || !info.apply) return;
19852        mixedLinePolicy.value = info.apply.mixed;
19853        pythonDocstrings.checked = !!info.apply.docstrings;
19854        document.getElementById("generated_file_detection").value = info.apply.generated;
19855        document.getElementById("minified_file_detection").value = info.apply.minified;
19856        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
19857        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
19858        document.getElementById("binary_file_behavior").value = info.apply.binary;
19859        updateMixedPolicyUI();
19860        updatePythonDocstringUI();
19861      }
19862
19863      function updateReview() {
19864        var scanSummary = document.getElementById("review-scan-summary");
19865        var countSummary = document.getElementById("review-count-summary");
19866        var artifactSummary = document.getElementById("review-artifact-summary");
19867        var outputSummary = document.getElementById("review-output-summary");
19868        var previewSummary = document.getElementById("review-preview-summary");
19869        var readinessSummary = document.getElementById("review-readiness-summary");
19870        var includeText = document.getElementById("include_globs").value.trim();
19871        var excludeText = document.getElementById("exclude_globs").value.trim();
19872        var sidePathPreview = document.getElementById("side-path-preview");
19873        var sideOutputPreview = document.getElementById("side-output-preview");
19874        var sideTitlePreview = document.getElementById("side-title-preview");
19875
19876        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
19877        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
19878        if (sideTitlePreview) {
19879          var rt = document.getElementById("report_title");
19880          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
19881        }
19882
19883        scanSummary.innerHTML = ""
19884          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
19885          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
19886          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
19887
19888        countSummary.innerHTML = ""
19889          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
19890          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
19891          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
19892          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
19893          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
19894          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
19895          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
19896          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
19897
19898        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
19899
19900        outputSummary.innerHTML = ""
19901          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
19902          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
19903
19904        if (previewSummary) {
19905          if (GIT_MODE) {
19906            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>';
19907          } else {
19908          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
19909          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
19910          var statMap = {};
19911          statButtons.forEach(function (button) {
19912            var valueNode = button.querySelector('.scope-stat-value');
19913            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
19914          });
19915          previewSummary.innerHTML = ''
19916            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
19917            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
19918            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
19919            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
19920            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
19921            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
19922
19923          if (readinessSummary) {
19924            readinessSummary.innerHTML = ''
19925              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
19926              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
19927              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
19928          }
19929          } // end else (non-GIT_MODE)
19930        }
19931      }
19932
19933      function escapeHtml(value) {
19934        return String(value)
19935          .replace(/&/g, "&amp;")
19936          .replace(/</g, "&lt;")
19937          .replace(/>/g, "&gt;")
19938          .replace(/"/g, "&quot;")
19939          .replace(/'/g, "&#39;");
19940      }
19941
19942      function isPythonVisible() {
19943        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
19944      }
19945
19946      function syncPythonVisibility() {
19947        var html = previewPanel.textContent || "";
19948        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
19949        pythonWraps.forEach(function (node) {
19950          node.classList.toggle("hidden", !hasPython);
19951        });
19952      }
19953
19954      function attachPreviewInteractions() {
19955        // Multiple-repository caution banner: gate step 1 until acknowledged, and
19956        // let each listed repo be picked as the scan root with one click.
19957        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
19958        if (multiRepoBanner) {
19959          multiRepoBlocked = true;
19960          refreshStep1Gate();
19961          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
19962          if (ackBox) {
19963            ackBox.addEventListener("change", function () {
19964              multiRepoBlocked = !ackBox.checked;
19965              refreshStep1Gate();
19966            });
19967          }
19968          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
19969          repoButtons.forEach(function (btn) {
19970            btn.addEventListener("click", function () {
19971              var repoPath = btn.getAttribute("data-repo-path") || "";
19972              if (!repoPath || !pathInput) return;
19973              pathInput.value = repoPath;
19974              scrollInputToEnd(pathInput);
19975              updateReportTitleFromPath();
19976              autoSetOutputDir(repoPath);
19977              fetchProjectHistory(repoPath);
19978              loadPreview();
19979              updateReview();
19980            });
19981          });
19982        }
19983        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
19984        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
19985        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
19986        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
19987        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
19988        var searchInput = previewPanel.querySelector("#explorer-search");
19989        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
19990        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
19991        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
19992        var activeFilter = "all";
19993        var activeLanguage = "";
19994        var searchTerm = "";
19995        var currentSortKey = null;
19996        var currentSortOrder = "asc";
19997        var childRows = {};
19998
19999        rows.forEach(function (row) {
20000          var parentId = row.getAttribute("data-parent-id") || "";
20001          var rowId = row.getAttribute("data-row-id") || "";
20002          if (!childRows[parentId]) childRows[parentId] = [];
20003          childRows[parentId].push(rowId);
20004        });
20005
20006        function rowById(id) {
20007          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20008        }
20009
20010        function hasCollapsedAncestor(row) {
20011          var parentId = row.getAttribute("data-parent-id");
20012          while (parentId) {
20013            var parent = rowById(parentId);
20014            if (!parent) break;
20015            if (parent.getAttribute("data-expanded") === "false") return true;
20016            parentId = parent.getAttribute("data-parent-id");
20017          }
20018          return false;
20019        }
20020
20021        function updateToggleGlyph(row) {
20022          var toggle = row.querySelector(".tree-toggle");
20023          if (!toggle) return;
20024          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20025        }
20026
20027        function rowSortValue(row, key) {
20028          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20029        }
20030
20031        function updateSortButtons() {
20032          sortButtons.forEach(function (button) {
20033            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20034            var indicator = button.querySelector(".tree-sort-indicator");
20035            button.classList.toggle("active", isActive);
20036            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20037            if (indicator) {
20038              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20039            }
20040          });
20041        }
20042
20043        function sortSiblingRows() {
20044          if (!treeContainer) {
20045            updateSortButtons();
20046            return;
20047          }
20048
20049          var rowMap = {};
20050          var childrenMap = {};
20051          rows.forEach(function (row) {
20052            var rowId = row.getAttribute("data-row-id");
20053            var parentId = row.getAttribute("data-parent-id") || "";
20054            rowMap[rowId] = row;
20055            if (!childrenMap[parentId]) childrenMap[parentId] = [];
20056            childrenMap[parentId].push(rowId);
20057          });
20058
20059          Object.keys(childrenMap).forEach(function (parentId) {
20060            if (!parentId) return;
20061            childrenMap[parentId].sort(function (a, b) {
20062              var rowA = rowMap[a];
20063              var rowB = rowMap[b];
20064              if (!currentSortKey) {
20065                return Number(a) - Number(b);
20066              }
20067              var valueA = rowSortValue(rowA, currentSortKey);
20068              var valueB = rowSortValue(rowB, currentSortKey);
20069              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20070              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20071              var fallbackA = rowSortValue(rowA, "name");
20072              var fallbackB = rowSortValue(rowB, "name");
20073              if (fallbackA < fallbackB) return -1;
20074              if (fallbackA > fallbackB) return 1;
20075              return Number(a) - Number(b);
20076            });
20077          });
20078
20079          var orderedIds = [];
20080          function pushChildren(parentId) {
20081            (childrenMap[parentId] || []).forEach(function (childId) {
20082              orderedIds.push(childId);
20083              pushChildren(childId);
20084            });
20085          }
20086
20087          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20088            orderedIds.push(topId);
20089            pushChildren(topId);
20090          });
20091
20092          orderedIds.forEach(function (id) {
20093            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20094          });
20095          updateSortButtons();
20096        }
20097
20098        function updateLanguageButtons() {
20099          languageButtons.forEach(function (button) {
20100            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20101            var isActive = languageValue === activeLanguage;
20102            button.classList.toggle("active", isActive);
20103          });
20104        }
20105
20106        function rowSelfMatches(row) {
20107          var kind = row.getAttribute("data-kind");
20108          var status = row.getAttribute("data-status");
20109          var language = (row.getAttribute("data-language") || "").toLowerCase();
20110          var name = row.getAttribute("data-name-lower") || "";
20111          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20112          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20113          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20114          var passesLanguage = !activeLanguage || language === activeLanguage;
20115          return passesFilter && passesSearch && passesLanguage;
20116        }
20117
20118        function hasMatchingDescendant(rowId) {
20119          return (childRows[rowId] || []).some(function (childId) {
20120            var childRow = rowById(childId);
20121            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20122          });
20123        }
20124
20125        function rowMatches(row) {
20126          if (rowSelfMatches(row)) return true;
20127          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20128        }
20129
20130        function resetViewState() {
20131          activeFilter = "all";
20132          activeLanguage = "";
20133          searchTerm = "";
20134          currentSortKey = null;
20135          currentSortOrder = "asc";
20136          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20137          if (searchInput) searchInput.value = "";
20138          if (filterSelect) filterSelect.value = "all";
20139          updateLanguageButtons();
20140        }
20141
20142        function applyVisibility() {
20143          rows.forEach(function (row) {
20144            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20145            row.classList.toggle("hidden-by-filter", !visible);
20146            row.style.display = visible ? "grid" : "none";
20147          });
20148          buttons.forEach(function (button) {
20149            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20150          });
20151          if (filterSelect) filterSelect.value = activeFilter;
20152        }
20153
20154        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20155        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20156        var originalStats = {};
20157        buttons.forEach(function (btn) {
20158          var f = btn.getAttribute('data-filter');
20159          var v = btn.querySelector('.scope-stat-value');
20160          if (f && v) originalStats[f] = v.textContent;
20161        });
20162
20163        function applySubmoduleStats(statsJson) {
20164          try {
20165            var s = JSON.parse(statsJson);
20166            buttons.forEach(function (btn) {
20167              var f = btn.getAttribute('data-filter');
20168              var v = btn.querySelector('.scope-stat-value');
20169              if (!v) return;
20170              if (f === 'dir') v.textContent = s.dirs;
20171              else if (f === 'file') v.textContent = s.files;
20172              else if (f === 'supported') v.textContent = s.supported;
20173              else if (f === 'skipped') v.textContent = s.skipped;
20174              else if (f === 'unsupported') v.textContent = s.unsupported;
20175            });
20176          } catch (e) {}
20177        }
20178
20179        function restoreBaseRepoStats() {
20180          buttons.forEach(function (btn) {
20181            var f = btn.getAttribute('data-filter');
20182            var v = btn.querySelector('.scope-stat-value');
20183            if (v && originalStats[f]) v.textContent = originalStats[f];
20184          });
20185          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20186          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20187        }
20188
20189        submoduleChips.forEach(function (chip) {
20190          chip.addEventListener('click', function () {
20191            var statsJson = chip.getAttribute('data-sub-stats');
20192            if (!statsJson) return;
20193            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20194            chip.classList.add('active');
20195            applySubmoduleStats(statsJson);
20196            if (baseRepoBtn) baseRepoBtn.style.display = '';
20197          });
20198        });
20199
20200        if (baseRepoBtn) {
20201          baseRepoBtn.addEventListener('click', function () {
20202            restoreBaseRepoStats();
20203            resetViewState();
20204            sortSiblingRows();
20205            applyVisibility();
20206          });
20207        }
20208
20209        buttons.forEach(function (button) {
20210          button.addEventListener("click", function () {
20211            var filterValue = button.getAttribute("data-filter") || "all";
20212            if (filterValue === "reset-view") {
20213              restoreBaseRepoStats();
20214              resetViewState();
20215              sortSiblingRows();
20216              applyVisibility();
20217              return;
20218            }
20219            activeFilter = filterValue;
20220            applyVisibility();
20221          });
20222        });
20223
20224        rows.forEach(function (row) {
20225          updateToggleGlyph(row);
20226          var toggle = row.querySelector(".tree-toggle");
20227          if (toggle) {
20228            toggle.addEventListener("click", function () {
20229              var expanded = row.getAttribute("data-expanded") !== "false";
20230              row.setAttribute("data-expanded", expanded ? "false" : "true");
20231              updateToggleGlyph(row);
20232              applyVisibility();
20233            });
20234          }
20235        });
20236
20237        actionButtons.forEach(function (button) {
20238          button.addEventListener("click", function () {
20239            var action = button.getAttribute("data-explorer-action");
20240            if (action === "expand-all") {
20241              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20242            } else if (action === "collapse-all") {
20243              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20244            } else if (action === "clear-filters") {
20245              resetViewState();
20246            }
20247            sortSiblingRows();
20248            applyVisibility();
20249          });
20250        });
20251
20252        if (filterSelect) {
20253          filterSelect.addEventListener("change", function () {
20254            activeFilter = filterSelect.value || "all";
20255            applyVisibility();
20256          });
20257        }
20258
20259        languageButtons.forEach(function (button) {
20260          button.addEventListener("click", function () {
20261            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20262            updateLanguageButtons();
20263            applyVisibility();
20264          });
20265        });
20266
20267        sortButtons.forEach(function (button) {
20268          button.addEventListener("click", function () {
20269            var sortKey = button.getAttribute("data-sort-key");
20270            if (currentSortKey === sortKey) {
20271              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20272            } else {
20273              currentSortKey = sortKey;
20274              currentSortOrder = "asc";
20275            }
20276            sortSiblingRows();
20277            applyVisibility();
20278          });
20279        });
20280
20281        if (searchInput) {
20282          searchInput.addEventListener("input", function () {
20283            searchTerm = searchInput.value.trim().toLowerCase();
20284            applyVisibility();
20285          });
20286        }
20287
20288        updateLanguageButtons();
20289        sortSiblingRows();
20290        applyVisibility();
20291      }
20292
20293      function loadPreview() {
20294        if (!previewPanel || !pathInput) return;
20295        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20296        multiRepoBlocked = false;
20297        refreshStep1Gate();
20298        if (GIT_MODE) {
20299          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>';
20300          setPreviewLoading(false);
20301          return;
20302        }
20303        var path = pathInput.value.trim();
20304        var zeroWarn = document.getElementById('zero-files-warning');
20305        if (!path) {
20306          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20307          if (zeroWarn) zeroWarn.style.display = 'none';
20308          setPreviewLoading(false);
20309          return;
20310        }
20311        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20312        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20313        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20314        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20315        var myGen = ++_previewGen;
20316        var _prevMsgs = [
20317          'Scanning directory structure\u2026',
20318          'Detecting file types\u2026',
20319          'Applying include / exclude filters\u2026',
20320          'Estimating file counts\u2026',
20321          'Building scope preview\u2026',
20322          'Almost there\u2026'
20323        ];
20324        var _prevMsgIdx = 0;
20325        var _prevStart = Date.now();
20326        previewPanel.innerHTML =
20327          '<div class="preview-loading">' +
20328          '<div class="preview-spinner"></div>' +
20329          '<div class="preview-loading-text">' +
20330          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20331          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20332          '</div></div>';
20333        var _sizeTextEl = document.getElementById('project-size-text');
20334        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20335        window._previewInterval = setInterval(function() {
20336          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20337          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20338          var ml = document.getElementById('plm');
20339          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20340        }, 1500);
20341        window._previewElapsedTimer = setInterval(function() {
20342          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20343          var el = document.getElementById('ple');
20344          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20345        }, 1000);
20346        setPreviewLoading(true);
20347        var previewUrl = "/preview?path=" + encodeURIComponent(path)
20348          + "&include_globs=" + encodeURIComponent(includeValue)
20349          + "&exclude_globs=" + encodeURIComponent(excludeValue);
20350        fetch(previewUrl)
20351          .then(function (response) { return response.text(); })
20352          .then(function (html) {
20353            if (myGen !== _previewGen) return;
20354            clearInterval(window._previewInterval); window._previewInterval = null;
20355            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20356            setPreviewLoading(false);
20357            previewPanel.innerHTML = html;
20358            attachPreviewInteractions();
20359            syncPythonVisibility();
20360            updateReview();
20361            setTimeout(collapseLanguagePills, 50);
20362            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20363            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20364            var sizeText = document.getElementById('project-size-text');
20365            var sizeBtn = document.getElementById('project-size-btn');
20366            // In server mode with upload sizes available, keep the compressed/original pair.
20367            if (SERVER_MODE && window._lastUploadSizes) {
20368              var us = window._lastUploadSizes;
20369              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20370                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20371              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20372                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20373            } else if (sizeText && projectSize) {
20374              sizeText.textContent = 'Project size: ' + projectSize;
20375              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20376            } else if (sizeText) {
20377              sizeText.textContent = 'Project size: \u2014';
20378            }
20379            if (zeroWarn) {
20380              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20381              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20382              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20383              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20384              if (supportedCount === 0 && fileCount > 0) {
20385                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).';
20386                zeroWarn.style.display = '';
20387              } else {
20388                zeroWarn.style.display = 'none';
20389              }
20390            }
20391          })
20392          .catch(function (err) {
20393            if (myGen !== _previewGen) return;
20394            clearInterval(window._previewInterval); window._previewInterval = null;
20395            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20396            setPreviewLoading(false);
20397            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20398          });
20399      }
20400
20401      function pickDirectory(targetInput, kind) {
20402        if (!targetInput) {
20403          showBannerToast("Directory picker: input element not found.", true);
20404          return;
20405        }
20406        if (SERVER_MODE) {
20407          if (kind === 'output') {
20408            showBannerToast(
20409              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20410              false,
20411              { top: true, icon: '\u{1F4C1}' }
20412            );
20413            return;
20414          }
20415          var inputEl = kind === 'coverage'
20416            ? document.getElementById('cov-upload-input')
20417            : document.getElementById('dir-upload-input');
20418          if (!inputEl) return;
20419          inputEl.onchange = function () {
20420            var files = inputEl.files;
20421            if (!files || files.length === 0) return;
20422            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20423            if (browseBtn) browseBtn.disabled = true;
20424
20425            function fileToBase64(file) {
20426              return new Promise(function (resolve, reject) {
20427                var reader = new FileReader();
20428                reader.onload = function () {
20429                  var b64 = reader.result.split(',')[1];
20430                  resolve(b64);
20431                };
20432                reader.onerror = reject;
20433                reader.readAsDataURL(file);
20434              });
20435            }
20436
20437            if (kind === 'coverage') {
20438              var f = files[0];
20439              if (previewPanel && targetInput === pathInput)
20440                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20441              fileToBase64(f).then(function (b64) {
20442                return fetch('/api/upload-file', {
20443                  method: 'POST',
20444                  headers: { 'Content-Type': 'application/json' },
20445                  body: JSON.stringify({ filename: f.name, content: b64 })
20446                }).then(function (r) { return r.json(); });
20447              })
20448                .then(function (d) {
20449                  if (d && d.tmp_path) {
20450                    if (coverageInput) coverageInput.value = d.tmp_path;
20451                    setCovStatus('idle');
20452                  } else if (d && d.error) { showBannerToast(d.error, true); }
20453                })
20454                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20455                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20456            } else {
20457              // ── Filter to source-code files only ─────────────────────────
20458              // Binary, generated, and dependency files (node_modules, .git,
20459              // build artifacts) are skipped so they are never uploaded.
20460              var CODE_EXTS = new Set([
20461                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20462                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20463                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20464                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20465                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20466                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20467                'tf','hcl','proto','thrift','avsc','graphql','gql'
20468              ]);
20469              var codeFiles = [];
20470              for (var i = 0; i < files.length; i++) {
20471                var f = files[i];
20472                var name = f.name;
20473                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20474                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20475                  codeFiles.push(f); continue;
20476                }
20477                var dot = name.lastIndexOf('.');
20478                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20479              }
20480              // Collect specific .git metadata files for server-side git detection.
20481              // These have no source extension so they are excluded by the loop above,
20482              // but the server needs them to read branch/commit/author without running git.
20483              var gitMetaFiles = [];
20484              for (var i = 0; i < files.length; i++) {
20485                var f = files[i];
20486                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20487                var gitIdx = rp.indexOf('/.git/');
20488                if (gitIdx < 0) continue;
20489                var gitRel = rp.slice(gitIdx + 1);
20490                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20491                    gitRel === '.git/logs/HEAD' ||
20492                    gitRel.startsWith('.git/refs/heads/') ||
20493                    gitRel.startsWith('.git/refs/tags/')) {
20494                  gitMetaFiles.push(f);
20495                }
20496              }
20497              var uploadFiles = codeFiles.concat(gitMetaFiles);
20498              var total = files.length;
20499              var kept = codeFiles.length;
20500              if (kept === 0) {
20501                if (previewPanel && targetInput === pathInput)
20502                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20503                if (browseBtn) browseBtn.disabled = false;
20504                inputEl.value = '';
20505                return;
20506              }
20507
20508              // ── Helper: apply upload result to UI ────────────────────────
20509              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20510              function applyUploadResult(tmpPath, sizes) {
20511                targetInput.value = tmpPath;
20512                scrollInputToEnd(targetInput);
20513                if (sizes && SERVER_MODE) {
20514                  window._lastUploadSizes = sizes;
20515                  // Immediately show both sizes before preview loads.
20516                  var sizeText = document.getElementById('project-size-text');
20517                  var sizeBtn = document.getElementById('project-size-btn');
20518                  if (sizeText) {
20519                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20520                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20521                  }
20522                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20523                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20524                }
20525                if (targetInput === pathInput) {
20526                  updateReportTitleFromPath();
20527                  autoSetOutputDir(tmpPath);
20528                  fetchProjectHistory(tmpPath);
20529                  loadPreview();
20530                  suggestCoverageFile(tmpPath);
20531                }
20532                updateReview();
20533                if (browseBtn) browseBtn.disabled = false;
20534                inputEl.value = '';
20535              }
20536
20537              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20538              if (typeof CompressionStream !== 'undefined') {
20539                if (previewPanel && targetInput === pathInput)
20540                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20541
20542                // Build a minimal POSIX ustar tar header for a single file entry.
20543                function buildUstarHeader(filePath, fileSize) {
20544                  var BLOCK = 512;
20545                  var hdr = new Uint8Array(BLOCK);
20546                  var enc = new TextEncoder();
20547                  function wStr(off, len, s) {
20548                    var b = enc.encode(s);
20549                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20550                  }
20551                  function wOct(off, len, val) {
20552                    var s = val.toString(8);
20553                    while (s.length < len - 1) s = '0' + s;
20554                    wStr(off, len, s + '\0');
20555                  }
20556                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20557                  var name = filePath, prefix = '';
20558                  if (filePath.length > 99) {
20559                    var split = filePath.lastIndexOf('/', 154);
20560                    if (split > 0 && filePath.length - split - 1 <= 99) {
20561                      prefix = filePath.substring(0, split);
20562                      name   = filePath.substring(split + 1);
20563                    } else { name = filePath.substring(0, 99); }
20564                  }
20565                  wStr(0,   100, name);          // name
20566                  wOct(100,   8, 0o000644);      // mode
20567                  wOct(108,   8, 0);             // uid
20568                  wOct(116,   8, 0);             // gid
20569                  wOct(124,  12, fileSize);      // size
20570                  wOct(136,  12, 0);             // mtime (epoch)
20571                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20572                  hdr[156] = 48;                 // type flag '0' = regular file
20573                  wStr(157, 100, '');            // linkname
20574                  wStr(257,   6, 'ustar');       // magic
20575                  wStr(263,   2, '00');          // version
20576                  wStr(265,  32, '');            // uname
20577                  wStr(297,  32, '');            // gname
20578                  wOct(329,   8, 0);             // devmajor
20579                  wOct(337,   8, 0);             // devminor
20580                  wStr(345, 155, prefix);        // prefix
20581                  // Compute checksum (sum of all bytes, placeholder = 32).
20582                  var chk = 0;
20583                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20584                  var cs = chk.toString(8);
20585                  while (cs.length < 6) cs = '0' + cs;
20586                  wStr(148, 8, cs + '\0 ');
20587                  return hdr;
20588                }
20589
20590                // Build tar.gz one file at a time, piping through CompressionStream.
20591                // RAM usage = compressed output buffer + one file at a time.
20592                (async function () {
20593                  try {
20594                    var BLOCK = 512;
20595                    var cs     = new CompressionStream('gzip');
20596                    var writer = cs.writable.getWriter();
20597                    var chunks = [];
20598                    var reader = cs.readable.getReader();
20599                    var collecting = (async function () {
20600                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20601                    })();
20602
20603                    for (var i = 0; i < uploadFiles.length; i++) {
20604                      var file = uploadFiles[i];
20605                      var path = file.webkitRelativePath || file.name;
20606                      var buf  = await file.arrayBuffer();
20607                      var data = new Uint8Array(buf);
20608                      // Header block
20609                      await writer.write(buildUstarHeader(path, data.length));
20610                      // Data padded to 512-byte boundary
20611                      if (data.length > 0) {
20612                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20613                        var block  = new Uint8Array(padded);
20614                        block.set(data);
20615                        await writer.write(block);
20616                      }
20617                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20618                        if (previewPanel && targetInput === pathInput)
20619                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20620                      }
20621                    }
20622                    // End-of-archive: two 512-byte zero blocks
20623                    await writer.write(new Uint8Array(BLOCK * 2));
20624                    await writer.close();
20625                    await collecting;
20626
20627                    var blob = new Blob(chunks, { type: 'application/gzip' });
20628                    var sizeMB = (blob.size / 1048576).toFixed(1);
20629                    if (previewPanel && targetInput === pathInput)
20630                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20631
20632                    var resp = await fetch('/api/upload-tarball', {
20633                      method: 'POST',
20634                      headers: { 'Content-Type': 'application/gzip' },
20635                      body: blob
20636                    });
20637                    var d = await resp.json();
20638                    if (d && d.tmp_path) {
20639                      applyUploadResult(d.tmp_path, {
20640                        compressed_bytes: d.compressed_bytes || 0,
20641                        original_bytes: d.original_bytes || 0
20642                      });
20643                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20644                  } catch (e) {
20645                    showBannerToast('Upload failed: ' + String(e), true);
20646                    if (browseBtn) browseBtn.disabled = false;
20647                    inputEl.value = '';
20648                  }
20649                })();
20650
20651              } else {
20652                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20653                // Used only on browsers that lack CompressionStream (pre-2023).
20654                var BATCH = 200;
20655                var batches = [];
20656                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20657                var totalBatches = batches.length;
20658                if (previewPanel && targetInput === pathInput)
20659                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20660
20661                function sendBatch(idx, currentUploadId, lastTmpPath) {
20662                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20663                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
20664                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
20665                  Promise.all(batches[idx].map(function (file) {
20666                    return fileToBase64(file).then(function (b64) {
20667                      return { path: file.webkitRelativePath || file.name, content: b64 };
20668                    });
20669                  })).then(function (fileList) {
20670                    var body = { files: fileList };
20671                    if (currentUploadId) body.upload_id = currentUploadId;
20672                    return fetch('/api/upload-directory', {
20673                      method: 'POST', headers: { 'Content-Type': 'application/json' },
20674                      body: JSON.stringify(body)
20675                    }).then(function (r) { return r.json(); });
20676                  }).then(function (d) {
20677                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
20678                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20679                  }).catch(function (e) {
20680                    showBannerToast('Upload failed: ' + String(e), true);
20681                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
20682                  });
20683                }
20684                sendBatch(0, null, '');
20685              }
20686            }
20687          };
20688          inputEl.click();
20689          return;
20690        }
20691
20692        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
20693        if (browseButton) browseButton.disabled = true;
20694
20695        if (previewPanel && targetInput === pathInput) {
20696          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
20697        }
20698
20699        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
20700          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
20701          .then(function (data) {
20702            if (data && data.selected_path) {
20703              targetInput.value = data.selected_path;
20704              scrollInputToEnd(targetInput);
20705
20706              if (targetInput === pathInput) {
20707                updateReportTitleFromPath();
20708                autoSetOutputDir(data.selected_path);
20709                fetchProjectHistory(data.selected_path);
20710                loadPreview();
20711                suggestCoverageFile(data.selected_path);
20712              }
20713
20714              updateReview();
20715            } else if (targetInput === pathInput) {
20716              loadPreview();
20717            }
20718          })
20719          .catch(function () {
20720            window.alert("Directory picker request failed.");
20721            if (previewPanel && targetInput === pathInput) {
20722              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
20723            }
20724          })
20725          .finally(function () {
20726            if (browseButton) browseButton.disabled = false;
20727          });
20728      }
20729
20730      if (themeToggle) {
20731        themeToggle.addEventListener("click", function () {
20732          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
20733          applyTheme(nextTheme);
20734          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
20735        });
20736      }
20737
20738      stepButtons.forEach(function (button) {
20739        button.addEventListener("click", function () {
20740          var target = Number(button.getAttribute("data-step-target"));
20741          // Block jumping forward off step 1 while the preview / upload is running
20742          // or while a multi-repository selection is unacknowledged.
20743          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20744          setStep(target);
20745        });
20746      });
20747
20748      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
20749        button.addEventListener("click", function () {
20750          var target = Number(button.getAttribute("data-step-target")) || 1;
20751          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
20752          setStep(target);
20753        });
20754      });
20755
20756      // True when the project path is untouched from the bundled sample default.
20757      function isDefaultSamplePath() {
20758        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
20759      }
20760
20761      var defaultPathOverlay = document.getElementById("default-path-overlay");
20762      function closeDefaultPathModal() {
20763        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
20764      }
20765      function openDefaultPathModal() {
20766        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
20767      }
20768
20769      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
20770        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
20771        // that borrow the .next-step style class but carry no data-next target).
20772        if (!button.hasAttribute("data-next")) return;
20773        button.addEventListener("click", function () {
20774          // Guard step 1 → 2: block while the scope preview / upload is still running
20775          // or while a multi-repository selection is unacknowledged.
20776          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
20777          // Guard step 1 → 2: warn when the project path is still the sample default.
20778          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
20779            openDefaultPathModal();
20780            return;
20781          }
20782          updateReview();
20783          setStep(Number(button.getAttribute("data-next")));
20784        });
20785      });
20786
20787      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
20788        if (!button.hasAttribute("data-prev")) return;
20789        button.addEventListener("click", function () {
20790          setStep(Number(button.getAttribute("data-prev")));
20791        });
20792      });
20793
20794      // Default-sample-path confirmation modal wiring.
20795      var defaultPathProceed = document.getElementById("default-path-proceed");
20796      if (defaultPathProceed) {
20797        defaultPathProceed.addEventListener("click", function () {
20798          closeDefaultPathModal();
20799          updateReview();
20800          setStep(2);
20801        });
20802      }
20803      var defaultPathCancel = document.getElementById("default-path-cancel");
20804      if (defaultPathCancel) {
20805        defaultPathCancel.addEventListener("click", function () {
20806          closeDefaultPathModal();
20807          if (pathInput) { pathInput.focus(); pathInput.select(); }
20808        });
20809      }
20810      if (defaultPathOverlay) {
20811        defaultPathOverlay.addEventListener("click", function (e) {
20812          if (e.target === defaultPathOverlay) closeDefaultPathModal();
20813        });
20814      }
20815      document.addEventListener("keydown", function (e) {
20816        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
20817          closeDefaultPathModal();
20818        }
20819      });
20820
20821      document.addEventListener("keydown", function (e) {
20822        var tag = (document.activeElement || {}).tagName || "";
20823        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
20824        if (e.altKey || e.ctrlKey || e.metaKey) return;
20825        if (e.key === "ArrowRight" && currentStep < 4) {
20826          if (currentStep === 1 && step1ForwardBlocked()) return;
20827          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
20828          updateReview(); setStep(currentStep + 1);
20829        }
20830        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
20831      });
20832
20833      if (useSamplePath) {
20834        useSamplePath.addEventListener("click", function () {
20835          pathInput.value = "testing/fixtures/basic";
20836          updateReportTitleFromPath();
20837          autoSetOutputDir("testing/fixtures/basic");
20838          loadPreview();
20839          suggestCoverageFile("testing/fixtures/basic");
20840        });
20841      }
20842
20843      if (useDefaultOutput) {
20844        useDefaultOutput.addEventListener("click", function () {
20845          delete outputDirInput.dataset.userEdited;
20846          autoSetOutputDir(pathInput ? pathInput.value : "");
20847          updateReview();
20848        });
20849      }
20850
20851      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
20852      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
20853
20854      // ── Drag-and-drop directory upload (server mode only) ─────────────────
20855      // Dropping a folder onto the path field bypasses Chrome's
20856      // "Upload X files to this site?" confirmation dialog.
20857      async function readDirRecursively(dirEntry, basePath) {
20858        var reader = dirEntry.createReader();
20859        var all = [];
20860        for (;;) {
20861          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
20862          if (!batch.length) break;
20863          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
20864        }
20865        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
20866        var out = [];
20867        for (var i = 0; i < all.length; i++) {
20868          var sub = all[i];
20869          if (sub.isFile) {
20870            var f = await new Promise(function(res) { sub.file(res); });
20871            out.push({ file: f, path: basePath + '/' + sub.name });
20872          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
20873            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
20874            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
20875          }
20876        }
20877        return out;
20878      }
20879
20880      function setupPathDropZone() {
20881        if (!SERVER_MODE || !pathInput) return;
20882        var CODE_EXTS = new Set([
20883          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20884          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20885          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20886          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20887          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20888          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
20889        ]);
20890        pathInput.addEventListener('dragover', function(e) {
20891          e.preventDefault();
20892          pathInput.classList.add('drag-over');
20893        });
20894        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
20895        pathInput.addEventListener('drop', function(e) {
20896          e.preventDefault();
20897          pathInput.classList.remove('drag-over');
20898          var items = e.dataTransfer.items;
20899          if (!items || !items.length) return;
20900          var dirEntry = null;
20901          for (var i = 0; i < items.length; i++) {
20902            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
20903            if (entry && entry.isDirectory) { dirEntry = entry; break; }
20904          }
20905          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
20906          var btn = browsePath;
20907          if (btn) btn.disabled = true;
20908          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
20909
20910          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
20911            var total = allEntries.length;
20912            var codeEntries = allEntries.filter(function(e) {
20913              var n = e.file.name;
20914              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
20915              var dot = n.lastIndexOf('.');
20916              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
20917            });
20918            var kept = codeEntries.length;
20919            if (kept === 0) {
20920              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
20921              if (btn) btn.disabled = false; return;
20922            }
20923
20924            function finish(tmpPath, sizes) {
20925              pathInput.value = tmpPath;
20926              scrollInputToEnd(pathInput);
20927              if (sizes) {
20928                window._lastUploadSizes = sizes;
20929                var sizeText = document.getElementById('project-size-text');
20930                var sizeBtn = document.getElementById('project-size-btn');
20931                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20932                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20933                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20934                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20935              }
20936              updateReportTitleFromPath();
20937              autoSetOutputDir(tmpPath);
20938              fetchProjectHistory(tmpPath);
20939              loadPreview();
20940              suggestCoverageFile(tmpPath);
20941              updateReview();
20942              if (btn) btn.disabled = false;
20943            }
20944
20945            if (typeof CompressionStream === 'undefined') {
20946              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
20947              if (btn) btn.disabled = false; return;
20948            }
20949
20950            try {
20951              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20952              var BLOCK = 512;
20953              var cs = new CompressionStream('gzip');
20954              var wtr = cs.writable.getWriter();
20955              var chunks = [];
20956              var rdr = cs.readable.getReader();
20957              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
20958
20959              function buildHdr(fp, sz) {
20960                var hdr = new Uint8Array(BLOCK);
20961                var enc = new TextEncoder();
20962                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]; }
20963                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
20964                var nm = fp, pfx = '';
20965                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); } }
20966                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
20967                for (var i = 148; i < 156; i++) hdr[i] = 32;
20968                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);
20969                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20970                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
20971                return hdr;
20972              }
20973
20974              for (var i = 0; i < codeEntries.length; i++) {
20975                var ce = codeEntries[i];
20976                var buf = await ce.file.arrayBuffer();
20977                var data = new Uint8Array(buf);
20978                await wtr.write(buildHdr(ce.path, data.length));
20979                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
20980                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
20981                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20982              }
20983              await wtr.write(new Uint8Array(BLOCK * 2));
20984              await wtr.close();
20985              await collecting;
20986
20987              var blob = new Blob(chunks, { type: 'application/gzip' });
20988              var sizeMB = (blob.size / 1048576).toFixed(1);
20989              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
20990              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
20991              var d = await resp.json();
20992              if (d && d.tmp_path) {
20993                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
20994              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
20995            } catch (err) {
20996              showBannerToast('Upload failed: ' + String(err), true);
20997              if (btn) btn.disabled = false;
20998            }
20999          }).catch(function(err) {
21000            showBannerToast('Could not read folder: ' + String(err), true);
21001            if (btn) btn.disabled = false;
21002          });
21003        });
21004      }
21005      setupPathDropZone();
21006      if (browseCoverage) {
21007        browseCoverage.addEventListener("click", function () {
21008          pickDirectory(coverageInput || pathInput, "coverage");
21009        });
21010      }
21011
21012      function setCovStatus(state, opts) {
21013        if (!covScanStatus) return;
21014        opts = opts || {};
21015        covScanStatus.className = "cov-scan-status cov-scan-" + state;
21016        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21017        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>';
21018        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>';
21019        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>';
21020        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>';
21021        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21022        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21023        if (state === "scanning") {
21024          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21025        } else if (state === "found") {
21026          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21027          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21028          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21029          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21030        } else if (state === "hint") {
21031          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21032          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
21033          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>';
21034        } else if (state === "none") {
21035          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21036          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
21037        }
21038        html += '</div></div>';
21039        covScanStatus.innerHTML = html;
21040        if (state === "found") {
21041          var useBtn = covScanStatus.querySelector(".cov-scan-use");
21042          if (useBtn) useBtn.addEventListener("click", function () {
21043            if (coverageInput) coverageInput.value = "";
21044            covAutoFilled = false;
21045            setCovStatus("idle");
21046          });
21047        }
21048      }
21049
21050      function suggestCoverageFile(projectPath) {
21051        if (!coverageInput || !covScanStatus) return;
21052        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21053        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21054        clearTimeout(coverageSuggestTimer);
21055        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21056        setCovStatus("scanning");
21057        coverageSuggestTimer = setTimeout(function () {
21058          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21059            .then(function (r) { return r.json(); })
21060            .then(function (d) {
21061              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21062              if (!d) { setCovStatus("none"); return; }
21063              if (d.found) {
21064                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21065                setCovStatus("found", { found: d.found, tool: d.tool });
21066              } else if (d.tool && d.hint) {
21067                setCovStatus("hint", { tool: d.tool, hint: d.hint });
21068              } else {
21069                setCovStatus("none");
21070              }
21071            })
21072            .catch(function () { setCovStatus("idle"); });
21073        }, 600);
21074      }
21075
21076      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21077
21078      if (coverageInput) coverageInput.addEventListener("input", function () {
21079        covAutoFilled = false;
21080        if (!this.value.trim()) setCovStatus("idle");
21081      });
21082
21083      // ── Language pill overflow: collapse to "+N more" chip ─────────────
21084      function collapseLanguagePills() {
21085        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21086        rows.forEach(function(row) {
21087          // Remove any previous overflow chip
21088          var prev = row.querySelector('.lang-overflow-chip');
21089          if (prev) prev.remove();
21090          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21091          pills.forEach(function(p) { p.style.display = ''; });
21092          if (!pills.length) return;
21093
21094          // Measure after restoring all pills
21095          var containerRight = row.getBoundingClientRect().right;
21096          var hidden = [];
21097          for (var i = pills.length - 1; i >= 1; i--) {
21098            var rect = pills[i].getBoundingClientRect();
21099            if (rect.right > containerRight + 2) {
21100              hidden.unshift(pills[i]);
21101              pills[i].style.display = 'none';
21102            } else {
21103              break;
21104            }
21105          }
21106
21107          if (hidden.length) {
21108            var chip = document.createElement('button');
21109            chip.type = 'button';
21110            chip.className = 'language-pill lang-overflow-chip';
21111            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21112            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21113            row.appendChild(chip);
21114          }
21115        });
21116      }
21117
21118      // Run after preview loads (preview panel populates language pills)
21119      var _origLoadPreviewCb = window.__previewLoaded;
21120      document.addEventListener('previewLoaded', collapseLanguagePills);
21121      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21122      setTimeout(collapseLanguagePills, 400);
21123
21124      // ── Project history & output dir auto-set ──────────────────────────
21125      var wsOutputRoot   = document.getElementById("ws-output-root");
21126      var wsScanCount    = document.getElementById("ws-scan-count");
21127      var wsLastScan     = document.getElementById("ws-last-scan");
21128      var historyBadge   = document.getElementById("path-history-badge");
21129      var historyTimer   = null;
21130
21131      var wsOutputLink = document.getElementById("ws-output-link");
21132      function syncStripOutputRoot() {
21133        var val = outputDirInput ? outputDirInput.value : "";
21134        var display = val || "project/sloc";
21135        if (wsOutputRoot) wsOutputRoot.textContent = display;
21136        if (wsOutputLink) wsOutputLink.dataset.folder = val;
21137      }
21138
21139      function scrollInputToEnd(input) {
21140        if (!input) return;
21141        // Defer so the DOM has the new value before we measure scroll width.
21142        requestAnimationFrame(function () {
21143          input.scrollLeft = input.scrollWidth;
21144          input.selectionStart = input.selectionEnd = input.value.length;
21145        });
21146      }
21147
21148      function autoSetOutputDir(projectPath) {
21149        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21150        if (GIT_MODE && GIT_OUTPUT_DIR) {
21151          outputDirInput.value = GIT_OUTPUT_DIR;
21152          scrollInputToEnd(outputDirInput);
21153          syncStripOutputRoot();
21154          updateReview();
21155          return;
21156        }
21157        if (!projectPath || !projectPath.trim()) return;
21158        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21159        outputDirInput.value = cleaned + "/sloc";
21160        scrollInputToEnd(outputDirInput);
21161        syncStripOutputRoot();
21162        updateReview();
21163      }
21164
21165      var wsBranch = document.getElementById("ws-branch");
21166
21167      function fetchProjectHistory(projectPath) {
21168        if (!projectPath || !projectPath.trim()) {
21169          if (wsScanCount) wsScanCount.textContent = "\u2014";
21170          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
21171          if (wsBranch)    wsBranch.textContent    = "\u2014";
21172          if (historyBadge) historyBadge.style.display = "none";
21173          return;
21174        }
21175        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21176          .then(function (r) { return r.ok ? r.json() : null; })
21177          .then(function (data) {
21178            if (!data) return;
21179            var countStr = data.scan_count > 0
21180              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21181              : "never";
21182            var tsStr = data.last_scan_timestamp
21183              ? data.last_scan_timestamp.replace(" UTC","")
21184              : "\u2014";
21185            if (wsScanCount) wsScanCount.textContent = countStr;
21186            if (wsLastScan)  wsLastScan.textContent  = tsStr;
21187            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
21188            if (data.scan_count > 0) {
21189              if (historyBadge) {
21190                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21191                historyBadge.textContent = data.scan_count + " previous scan" +
21192                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21193                  "Last: " + (data.last_scan_timestamp || "\u2014") +
21194                  " \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.";
21195                historyBadge.className = "path-history-badge found";
21196                historyBadge.style.display = "";
21197              }
21198            } else {
21199              if (historyBadge) historyBadge.style.display = "none";
21200            }
21201          })
21202          .catch(function () {});
21203      }
21204
21205      function onPathChange() {
21206        var val = pathInput ? pathInput.value : "";
21207        // Discard stale upload sizes when the user edits the path manually.
21208        window._lastUploadSizes = null;
21209        updateReportTitleFromPath();
21210        autoSetOutputDir(val);
21211        updateSidebarSummary();
21212        clearTimeout(historyTimer);
21213        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21214        if (previewTimer) clearTimeout(previewTimer);
21215        previewTimer = setTimeout(loadPreview, 280);
21216        suggestCoverageFile(val);
21217      }
21218
21219      if (pathInput) {
21220        pathInput.addEventListener("input", onPathChange);
21221      }
21222
21223      if (outputDirInput) {
21224        outputDirInput.addEventListener("input", function () {
21225          outputDirInput.dataset.userEdited = "1";
21226          syncStripOutputRoot();
21227          updateReview();
21228        });
21229      }
21230
21231      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21232        if (!node) return;
21233        node.addEventListener("input", function () {
21234          updateReview();
21235          if (previewTimer) clearTimeout(previewTimer);
21236          previewTimer = setTimeout(loadPreview, 280);
21237        });
21238      });
21239
21240      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21241        var node = document.getElementById(id);
21242        if (node) node.addEventListener("change", updateReview);
21243      });
21244
21245      if (reportTitleInput) {
21246        reportTitleInput.addEventListener("input", function () {
21247          reportTitleTouched = reportTitleInput.value.trim().length > 0;
21248          updateReportTitleFromPath();
21249          updateReview();
21250        });
21251      }
21252
21253      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21254      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21255      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21256      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21257
21258      if (coverageInput) {
21259        coverageInput.addEventListener("input", function () {
21260          if (coverageInput.value.trim()) setCovStatus("idle");
21261        });
21262      }
21263
21264      if (form && loading && submitButton) {
21265        form.addEventListener("submit", function (e) {
21266          e.preventDefault();
21267          submitButton.disabled = true;
21268          submitButton.textContent = "Scanning...";
21269          startAsyncAnalysis(new FormData(form));
21270        });
21271      }
21272
21273      function openPath(folder) {
21274        if (!folder) return;
21275        fetch('/open-path?path=' + encodeURIComponent(folder))
21276          .then(function (r) { return r.json(); })
21277          .then(function (d) {
21278            if (d && d.server_mode_disabled)
21279              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21280          })
21281          .catch(function () {});
21282      }
21283
21284      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21285        btn.addEventListener('click', function () {
21286          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21287        });
21288      });
21289
21290      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21291      if (wsOutputLink) {
21292        wsOutputLink.addEventListener('click', function () {
21293          openPath(wsOutputLink.dataset.folder || '');
21294        });
21295      }
21296
21297      loadSavedTheme();
21298      updateMixedPolicyUI();
21299      updatePythonDocstringUI();
21300      applyScanPreset();
21301      updatePresetDescriptions();
21302      applyArtifactPreset();
21303      updateReview();
21304      updateScrollProgress(); // initialise bar to 0% (step 1)
21305      window.addEventListener("scroll", updateScrollProgress, { passive: true });
21306      onPathChange();         // seed output dir, history badge, and preview from initial path
21307      updateStepNav(1);
21308
21309      // Restore step from URL hash on initial load (e.g., back-forward cache)
21310      (function() {
21311        var hashMatch = location.hash.match(/^#step([1-4])$/);
21312        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21313      })();
21314
21315      (function randomizeWatermarks() {
21316        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21317        if (!wms.length) return;
21318        var placed = [];
21319        function tooClose(top, left) {
21320          for (var i = 0; i < placed.length; i++) {
21321            var dt = Math.abs(placed[i][0] - top);
21322            var dl = Math.abs(placed[i][1] - left);
21323            if (dt < 16 && dl < 12) return true;
21324          }
21325          return false;
21326        }
21327        function pick(leftBand) {
21328          for (var attempt = 0; attempt < 50; attempt++) {
21329            var top = Math.random() * 88 + 2;
21330            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21331            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21332          }
21333          var top = Math.random() * 88 + 2;
21334          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21335          placed.push([top, left]);
21336          return [top, left];
21337        }
21338        var half = Math.floor(wms.length / 2);
21339        wms.forEach(function (img, i) {
21340          var pos = pick(i < half);
21341          var size = Math.floor(Math.random() * 80 + 110);
21342          var rot = (Math.random() * 360).toFixed(1);
21343          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21344          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;
21345        });
21346      })();
21347
21348      (function spawnCodeParticles() {
21349        var container = document.getElementById('code-particles');
21350        if (!container) return;
21351        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'];
21352        for (var i = 0; i < 38; i++) {
21353          (function(idx) {
21354            var el = document.createElement('span');
21355            el.className = 'code-particle';
21356            el.textContent = snippets[idx % snippets.length];
21357            var left = Math.random() * 94 + 2;
21358            var top = Math.random() * 88 + 6;
21359            var dur = (Math.random() * 10 + 9).toFixed(1);
21360            var delay = (Math.random() * 18).toFixed(1);
21361            var rot = (Math.random() * 26 - 13).toFixed(1);
21362            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21363            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';
21364            container.appendChild(el);
21365          })(i);
21366        }
21367      })();
21368    })();
21369  </script>
21370  <script nonce="{{ csp_nonce }}">
21371    (function () {
21372      var raw = {{ prefill_json|safe }};
21373      if (!raw || typeof raw !== 'object' || !raw.path) return;
21374      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21375      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21376      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21377      setVal('path', raw.path || '');
21378      setVal('include_globs', raw.include_globs || '');
21379      setVal('exclude_globs', raw.exclude_globs || '');
21380      setVal('output_dir', raw.output_dir || '');
21381      setVal('report_title', raw.report_title || '');
21382      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21383      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21384      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21385      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21386      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21387      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21388      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21389      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21390      setChecked('generate_html', raw.generate_html !== false);
21391      setChecked('generate_pdf', !!raw.generate_pdf);
21392      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21393      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21394      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21395      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21396      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21397      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21398      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21399      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21400      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21401      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21402      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21403      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21404      // Trigger dynamic UI updates after pre-fill.
21405      setTimeout(function () {
21406        var pathEl = document.getElementById('path');
21407        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21408        var policyEl = document.getElementById('mixed_line_policy');
21409        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21410      }, 80);
21411    })();
21412  </script>
21413  <script nonce="{{ csp_nonce }}">
21414  (function(){
21415    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'}];
21416    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);});}
21417    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21418    function init(){
21419      var btn=document.getElementById('settings-btn');if(!btn)return;
21420      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21421      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>';
21422      document.body.appendChild(m);
21423      var g=document.getElementById('scheme-grid');
21424      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);});
21425      var cl=document.getElementById('settings-close');
21426      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);});})();
21427      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');});
21428      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21429      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21430    }
21431    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21432  }());
21433  </script>
21434  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21435    <div class="wb-ftip-arrow"></div>
21436    <span id="wb-ftip-text"></span>
21437  </div>
21438  <script nonce="{{ csp_nonce }}">(function(){
21439    var tip=document.getElementById('wb-ftip');
21440    var txt=document.getElementById('wb-ftip-text');
21441    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21442    if(!tip||!txt)return;
21443    function pos(el){
21444      var r=el.getBoundingClientRect();
21445      tip.style.display='block';
21446      var tw=tip.offsetWidth;
21447      var lx=r.left+r.width/2-tw/2;
21448      if(lx<8)lx=8;
21449      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21450      tip.style.left=lx+'px';
21451      tip.style.top=(r.bottom+8)+'px';
21452      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';}
21453    }
21454    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21455      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21456      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21457    });
21458    window.addEventListener('blur',function(){tip.style.display='none';});
21459    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21460  })();
21461  (function(){
21462    function fixArtifactHintSpacing(){
21463      var grid=document.querySelector('.artifact-grid');
21464      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21465    }
21466    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21467  }());
21468  (function(){
21469    var dot=document.getElementById('status-dot');
21470    var pingEl=document.getElementById('server-ping-ms');
21471    var tipEl=document.getElementById('server-tip-ping');
21472    var fm=document.getElementById('footer-mode');
21473    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)';}}
21474    function doPing(){
21475      var t0=performance.now();
21476      fetch('/healthz',{cache:'no-store'})
21477        .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);})
21478        .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)';}});
21479    }
21480    doPing();
21481    setInterval(doPing,5000);
21482    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');}
21483  })();
21484  </script>
21485  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21486  <footer class="site-footer">
21487    local code analysis - metrics, history and reports
21488    &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>
21489    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21490    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21491    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21492    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21493  </footer>
21494</body>
21495</html>
21496"##,
21497    ext = "html"
21498)]
21499struct IndexTemplate {
21500    version: &'static str,
21501    prefill_json: String,
21502    csp_nonce: String,
21503    git_repo: String,
21504    git_ref: String,
21505    git_label_json: String,
21506    git_output_dir_json: String,
21507    server_mode: bool,
21508}
21509
21510// ── SplashTemplate ────────────────────────────────────────────────────────────
21511
21512#[derive(Template)]
21513#[template(
21514    source = r##"
21515<!doctype html>
21516<html lang="en">
21517<head>
21518  <meta charset="utf-8">
21519  <meta name="viewport" content="width=device-width, initial-scale=1">
21520  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21521  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21522  <script type="application/ld+json">
21523  {
21524    "@context": "https://schema.org",
21525    "@type": "SoftwareApplication",
21526    "name": "oxide-sloc",
21527    "applicationCategory": "DeveloperApplication",
21528    "operatingSystem": "Windows, Linux",
21529    "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.",
21530    "softwareVersion": "{{ version }}",
21531    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21532    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21533    "url": "https://github.com/oxide-sloc/oxide-sloc",
21534    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21535    "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",
21536    "programmingLanguage": "Rust",
21537    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21538  }
21539  </script>
21540  <style nonce="{{ csp_nonce }}">
21541    :root {
21542      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21543      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21544      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21545      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21546      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21547    }
21548    body.dark-theme {
21549      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21550      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21551    }
21552    *{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;}
21553    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21554    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21555    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21556    .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;}
21557    @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));}}
21558    .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);}
21559    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21560    .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));}
21561    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21562    .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;}
21563    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21564    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21565    @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; } }
21566    .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;}
21567    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21568    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21569    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21570    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21571    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21572    .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;}
21573    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21574    .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);}
21575    .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;}
21576    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21577    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21578    .settings-modal-body{padding:14px 16px 16px;}
21579    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21580    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21581    .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;}
21582    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21583    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21584    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21585    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21586    .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;}
21587    .tz-select:focus{border-color:var(--oxide);}
21588    .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;}
21589    .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;}
21590    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21591    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21592    .hero{text-align:center;margin:0 auto 18px;}
21593    .hero-logo-wrap{display:inline-block;cursor:default;}
21594    .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;}
21595    .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;}
21596    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21597    .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;}
21598    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%);}
21599    .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;
21600      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21601      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21602      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;}
21603    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21604    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21605    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;}
21606    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21607    .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;}
21608    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21609    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21610    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21611    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21612    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21613    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21614    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21615    .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;}
21616    .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;}
21617    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21618    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21619    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21620    .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);}
21621    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21622    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21623    .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);}
21624    .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);}
21625    .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);}
21626    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21627    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21628    .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;}
21629    body.dark-theme .action-card-cta{color:var(--oxide);}
21630    .action-card.view .action-card-cta{color:var(--accent-2);}
21631    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21632    .action-card.compare .action-card-cta{color:#7c3aed;}
21633    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21634    .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);}
21635    .action-card.git-tools .action-card-cta{color:#15803d;}
21636    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21637    .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);}
21638    .action-card.trend .action-card-cta{color:#0e7490;}
21639    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21640    .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);}
21641    .action-card.automation .action-card-cta{color:#b45309;}
21642    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21643    .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);}
21644    .action-card.test-metrics .action-card-cta{color:#be185d;}
21645    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21646    .action-card:hover .action-card-cta{gap:12px;}
21647    .action-card.card-split{flex-direction:row;align-items:stretch;}
21648    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21649    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21650    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21651    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21652    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21653    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21654    .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;}
21655    .ac-badge.active{opacity:1;}
21656    .ac-badge.github{border-color:#555;color:#555;}
21657    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21658    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21659    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21660    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21661    body.dark-theme .ac-right-row{color:var(--muted);}
21662    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21663    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
21664    .divider{height:1px;background:var(--line);margin:32px 0;}
21665    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
21666    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
21667    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
21668    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
21669      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
21670    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21671    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
21672    body.dark-theme .info-chip-val{color:var(--oxide);}
21673    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
21674    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
21675      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
21676      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
21677    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
21678      border:6px solid transparent;border-top-color:var(--text);}
21679    .info-chip:hover .info-chip-tip{display:block;}
21680    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
21681    .chip-slide.fading{filter:blur(5px);opacity:0;}
21682    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21683    .site-footer a{color:var(--muted);}
21684    .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;}
21685    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
21686    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
21687    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
21688    .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;}
21689    .lan-badge.local{background:var(--oxide-2);}
21690    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
21691    .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);}
21692    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
21693    .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;}
21694    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
21695    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
21696    .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;}
21697    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
21698    .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;}
21699    .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);}
21700    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
21701    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
21702    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
21703    .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;}
21704    @media (max-height: 1100px) {
21705      .page{padding-top:10px;}
21706      .hero{margin-bottom:10px;}
21707      .hero-logo{width:54px;height:60px;}
21708      .hero-logo-shadow{width:42px;}
21709      .hero-title{font-size:28px;}
21710      .hero-subtitle{font-size:13px;}
21711      .card-sections{gap:12px;margin-bottom:6px;}
21712      .card-section-grid-2,.card-section-grid-3{gap:10px;}
21713      .action-card{padding:8px 15px 8px;}
21714      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
21715      .action-card-icon svg{width:18px;height:18px;}
21716      .action-card-title{font-size:13px;}
21717      .action-card-desc{font-size:11px;margin-bottom:6px;}
21718      .action-card-cta{font-size:11px;}
21719      .ac-right-row{font-size:11px;}
21720      .divider{margin:14px 0;}
21721      .info-strip{gap:7px;margin-bottom:8px;}
21722      .info-chip{padding:7px 10px;}
21723      .info-chip-val{font-size:13px;}
21724      .info-chip-label{font-size:9px;}
21725      .site-footer{padding:8px 24px;font-size:12px;}
21726      .lan-local-hint{margin-top:8px;}
21727    }
21728    @media (max-height: 850px) {
21729      .page{padding-top:6px;}
21730      .hero{margin-bottom:6px;}
21731      .hero-logo{width:42px;height:46px;}
21732      .hero-title{font-size:22px;}
21733      .hero-subtitle{font-size:12px;}
21734      .card-sections{gap:10px;}
21735      .action-card-desc{margin-bottom:4px;}
21736      .divider{margin:8px 0;}
21737      .info-strip{margin-bottom:6px;}
21738      .lan-local-hint{margin-top:10px;}
21739    }
21740  </style>
21741</head>
21742<body>
21743  <div class="background-watermarks" aria-hidden="true">
21744    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21745    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21746    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21747    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21748    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21749    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21750    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
21751  </div>
21752  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21753  <div class="top-nav">
21754    <div class="top-nav-inner">
21755      <a class="brand" href="/">
21756        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
21757        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
21758      </a>
21759      <div class="nav-right">
21760        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
21761        <div class="nav-dropdown">
21762          <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>
21763          <div class="nav-dropdown-menu">
21764            <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>
21765          </div>
21766        </div>
21767        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21768        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21769        <div class="nav-dropdown">
21770          <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>
21771          <div class="nav-dropdown-menu">
21772            <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>
21773          </div>
21774        </div>
21775        <div class="server-status-wrap" id="server-status-wrap">
21776          <div class="nav-pill server-online-pill" id="server-status-pill">
21777            <span class="status-dot" id="status-dot"></span>
21778            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21779            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
21780          </div>
21781          <div class="server-status-tip">
21782            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
21783            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
21784          </div>
21785        </div>
21786        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21787          <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>
21788        </button>
21789        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
21790          <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>
21791          <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>
21792        </button>
21793      </div>
21794    </div>
21795  </div>
21796
21797  <div class="page">
21798    <div class="hero">
21799      <div class="hero-logo-wrap" id="hero-logo-wrap">
21800        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
21801      </div>
21802      <div class="hero-logo-shadow"></div>
21803      <div class="hero-title-wrap">
21804        <div class="hero-title-aura" aria-hidden="true"></div>
21805        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
21806      </div>
21807      <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>
21808    </div>
21809
21810    <div class="card-sections">
21811
21812      <div>
21813        <div class="card-section-label">Analysis</div>
21814        <div class="card-section-grid-2">
21815          <a class="action-card scan card-split" href="/scan-setup">
21816            <div class="action-card-left">
21817              <div class="action-card-icon">
21818                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
21819              </div>
21820              <div class="action-card-title">Scan Project</div>
21821              <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>
21822              <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>
21823            </div>
21824            <div class="action-card-sep"></div>
21825            <div class="action-card-right">
21826              <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>
21827              <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>
21828              <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>
21829              <div class="ac-right-stat" id="acp-scan-stat"></div>
21830            </div>
21831          </a>
21832          <a class="action-card test-metrics card-split" href="/test-metrics">
21833            <div class="action-card-left">
21834              <div class="action-card-icon">
21835                <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>
21836              </div>
21837              <div class="action-card-title">Test Metrics</div>
21838              <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>
21839              <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>
21840            </div>
21841            <div class="action-card-sep"></div>
21842            <div class="action-card-right">
21843              <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>
21844              <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>
21845              <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>
21846              <div class="ac-right-stat" id="acp-test-stat"></div>
21847            </div>
21848          </a>
21849        </div>
21850      </div>
21851
21852      <div>
21853        <div class="card-section-label">Reports &amp; Insights</div>
21854        <div class="card-section-grid-3">
21855          <a class="action-card view" href="/view-reports">
21856            <div class="action-card-icon">
21857              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
21858            </div>
21859            <div class="action-card-title">View Reports</div>
21860            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
21861            <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>
21862          </a>
21863          <a class="action-card compare" href="/compare-scans">
21864            <div class="action-card-icon">
21865              <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>
21866            </div>
21867            <div class="action-card-title">Compare Scans</div>
21868            <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>
21869            <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>
21870          </a>
21871          <a class="action-card trend" href="/trend-reports">
21872            <div class="action-card-icon">
21873              <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>
21874            </div>
21875            <div class="action-card-title">Trend Report</div>
21876            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
21877            <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>
21878          </a>
21879        </div>
21880      </div>
21881
21882      <div>
21883        <div class="card-section-label">Developer Tools</div>
21884        <div class="card-section-grid-2">
21885          <a class="action-card git-tools card-split" href="/git-browser">
21886            <div class="action-card-left">
21887              <div class="action-card-icon">
21888                <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>
21889              </div>
21890              <div class="action-card-title">Git Browser</div>
21891              <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>
21892              <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>
21893            </div>
21894            <div class="action-card-sep"></div>
21895            <div class="action-card-right">
21896              <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>
21897              <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>
21898              <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>
21899            </div>
21900          </a>
21901          <a class="action-card automation card-split" href="/integrations">
21902            <div class="action-card-left">
21903              <div class="action-card-icon">
21904                <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>
21905              </div>
21906              <div class="action-card-title">Integrations</div>
21907              <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>
21908              <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>
21909            </div>
21910            <div class="action-card-sep"></div>
21911            <div class="action-card-right">
21912              <div class="ac-badges-grid">
21913                <span class="ac-badge github"     id="acp-gh">GitHub</span>
21914                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
21915                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
21916                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
21917              </div>
21918              <div class="ac-right-stat" id="acp-int-stat"></div>
21919            </div>
21920          </a>
21921        </div>
21922      </div>
21923
21924    </div>
21925
21926    {% if server_mode %}
21927    <div class="lan-card server">
21928      <div class="lan-card-header">
21929        <span class="lan-badge">LAN server</span>
21930        Accessible on your network
21931      </div>
21932      {% if let Some(ip) = lan_ip %}
21933      <div class="lan-url-row">
21934        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
21935        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
21936          <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>
21937          Copy URL
21938        </button>
21939      </div>
21940      <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>
21941      {% if has_api_key %}
21942      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
21943      {% endif %}
21944      {% else %}
21945      <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>
21946      {% endif %}
21947    </div>
21948    {% endif %}
21949
21950    <div class="divider"></div>
21951
21952    <div class="info-strip">
21953      <div class="info-chip">
21954        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
21955        <div class="chip-slide">
21956          <div class="info-chip-val">60</div>
21957          <div class="info-chip-label">Languages</div>
21958        </div>
21959      </div>
21960      <div class="info-chip">
21961        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
21962        <div class="chip-slide">
21963          <div class="info-chip-val">100%</div>
21964          <div class="info-chip-label">Self-contained</div>
21965        </div>
21966      </div>
21967      <div class="info-chip">
21968        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
21969        <div class="chip-slide">
21970          <div class="info-chip-val">HTML+PDF</div>
21971          <div class="info-chip-label">Exportable reports</div>
21972        </div>
21973      </div>
21974      <div class="info-chip">
21975        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
21976        <div class="chip-slide">
21977          <div class="info-chip-val">Webhook</div>
21978          <div class="info-chip-label">3 platforms</div>
21979        </div>
21980      </div>
21981      <div class="info-chip">
21982        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
21983        <div class="chip-slide">
21984          <div class="info-chip-val">IEEE</div>
21985          <div class="info-chip-label">1045-1992</div>
21986        </div>
21987      </div>
21988    </div>
21989
21990    {% if lan_ip.is_none() %}
21991    <div class="lan-local-hint">
21992      <strong>Want teammates on the same network to access this?</strong><br>
21993      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
21994    </div>
21995    {% endif %}
21996  </div>
21997
21998  <footer class="site-footer">
21999    local code analysis - metrics, history and reports
22000    &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>
22001    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22002    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22003    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22004    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22005  </footer>
22006
22007  <script nonce="{{ csp_nonce }}">
22008    (function () {
22009      var storageKey = 'oxide-sloc-theme';
22010      var body = document.body;
22011      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22012      var toggle = document.getElementById('theme-toggle');
22013      if (toggle) toggle.addEventListener('click', function () {
22014        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22015        body.classList.toggle('dark-theme', next === 'dark');
22016        try { localStorage.setItem(storageKey, next); } catch(e) {}
22017      });
22018      var copyBtn = document.getElementById('lan-copy-btn');
22019      if (copyBtn) copyBtn.addEventListener('click', function() {
22020        var btn = this;
22021        var el = document.getElementById('lan-url-val');
22022        if (!el) return;
22023        var url = el.textContent.trim();
22024        if (navigator.clipboard) {
22025          navigator.clipboard.writeText(url).then(function() {
22026            var orig = btn.innerHTML;
22027            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!';
22028            setTimeout(function() { btn.innerHTML = orig; }, 1800);
22029          });
22030        }
22031      });
22032      (function randomizeWatermarks() {
22033        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22034        if (!wms.length) return;
22035        var placed = [];
22036        function tooClose(top, left) {
22037          for (var i = 0; i < placed.length; i++) {
22038            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22039            if (dt < 16 && dl < 12) return true;
22040          }
22041          return false;
22042        }
22043        function pick(leftBand) {
22044          for (var attempt = 0; attempt < 50; attempt++) {
22045            var top = Math.random() * 88 + 2;
22046            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22047            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22048          }
22049          var top = Math.random() * 88 + 2;
22050          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22051          placed.push([top, left]); return [top, left];
22052        }
22053        var half = Math.floor(wms.length / 2);
22054        wms.forEach(function (img, i) {
22055          var pos = pick(i < half);
22056          var size = Math.floor(Math.random() * 100 + 120);
22057          var rot = (Math.random() * 360).toFixed(1);
22058          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22059          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;
22060        });
22061      })();
22062
22063      (function spawnCodeParticles() {
22064        var container = document.getElementById('code-particles');
22065        if (!container) return;
22066        var snippets = [
22067          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22068          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22069          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22070          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22071          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22072        ];
22073        var count = 38;
22074        for (var i = 0; i < count; i++) {
22075          (function(idx) {
22076            var el = document.createElement('span');
22077            el.className = 'code-particle';
22078            var text = snippets[idx % snippets.length];
22079            el.textContent = text;
22080            var left = Math.random() * 94 + 2;
22081            var top = Math.random() * 88 + 6;
22082            var dur = (Math.random() * 10 + 9).toFixed(1);
22083            var delay = (Math.random() * 18).toFixed(1);
22084            var rot = (Math.random() * 26 - 13).toFixed(1);
22085            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22086            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22087              + '--rot:' + rot + 'deg;--op:' + op + ';'
22088              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22089            container.appendChild(el);
22090          })(i);
22091        }
22092      })();
22093      (function heroAnimations() {
22094        var sub = document.getElementById('hero-subtitle');
22095        if (sub) {
22096          var full = sub.textContent.trim();
22097          sub.textContent = '';
22098          sub.style.opacity = '1';
22099          var cursor = document.createElement('span');
22100          cursor.className = 'hero-cursor';
22101          sub.appendChild(cursor);
22102          var i = 0;
22103          setTimeout(function() {
22104            var iv = setInterval(function() {
22105              if (i < full.length) {
22106                sub.insertBefore(document.createTextNode(full[i]), cursor);
22107                i++;
22108              } else {
22109                clearInterval(iv);
22110                setTimeout(function() {
22111                  cursor.style.transition = 'opacity 1s ease';
22112                  cursor.style.opacity = '0';
22113                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22114                }, 2400);
22115              }
22116            }, 11);
22117          }, 374);
22118        }
22119      })();
22120      (function logoBob() {
22121        var logo = document.querySelector('.hero-logo');
22122        var shadow = document.querySelector('.hero-logo-shadow');
22123        if (!logo) return;
22124        var cycleStart = null, cycleDur = 3600;
22125        var peakY = -14, peakScale = 1.07, peakRot = 0;
22126        function newCycle() {
22127          cycleDur = 3000 + Math.random() * 1840;
22128          peakY = -(9 + Math.random() * 13.8);
22129          peakScale = 1.04 + Math.random() * 0.081;
22130          peakRot = (Math.random() * 11.5 - 5.75);
22131        }
22132        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22133        newCycle();
22134        function frame(ts) {
22135          if (cycleStart === null) cycleStart = ts;
22136          var t = (ts - cycleStart) / cycleDur;
22137          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22138          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22139          var y = peakY * phase;
22140          var sc = 1 + (peakScale - 1) * phase;
22141          var rot = peakRot * Math.sin(Math.PI * phase);
22142          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22143          if (shadow) {
22144            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22145            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22146          }
22147          requestAnimationFrame(frame);
22148        }
22149        requestAnimationFrame(frame);
22150      })();
22151      (function mouseEffects() {
22152        var heroTitle = document.getElementById('hero-title');
22153        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22154        function tick() {
22155          raf = null;
22156          if (heroTitle) {
22157            var r = heroTitle.getBoundingClientRect();
22158            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22159            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22160            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22161          }
22162        }
22163        document.addEventListener('mousemove', function(e) {
22164          mx = e.clientX; my = e.clientY;
22165          if (!raf) raf = requestAnimationFrame(tick);
22166        });
22167        document.addEventListener('mouseleave', function() {
22168          if (heroTitle) {
22169            heroTitle.style.transition = 'transform 0.5s ease';
22170            heroTitle.style.transform = '';
22171            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22172          }
22173        });
22174        document.querySelectorAll('.action-card').forEach(function(card) {
22175          card.addEventListener('mousemove', function(e) {
22176            var rect = card.getBoundingClientRect();
22177            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22178            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22179            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22180            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22181          });
22182          card.addEventListener('mouseleave', function() {
22183            card.style.transition = '';
22184            card.style.transform = '';
22185          });
22186        });
22187      })();
22188      (function chipSlideshow() {
22189        var slides = [
22190          [{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'}],
22191          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22192          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22193          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22194          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22195        ];
22196        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22197        var indices = [0,0,0,0,0];
22198        var paused = [false,false,false,false,false];
22199        chips.forEach(function(chip, i) {
22200          chip.addEventListener('mouseenter', function() { paused[i] = true; });
22201          chip.addEventListener('mouseleave', function() { paused[i] = false; });
22202        });
22203        function advance(i) {
22204          if (paused[i]) return;
22205          var chip = chips[i];
22206          var inner = chip.querySelector('.chip-slide');
22207          if (!inner) return;
22208          inner.classList.add('fading');
22209          setTimeout(function() {
22210            indices[i] = (indices[i] + 1) % slides[i].length;
22211            var s = slides[i][indices[i]];
22212            chip.querySelector('.info-chip-val').textContent = s.v;
22213            chip.querySelector('.info-chip-label').textContent = s.l;
22214            inner.classList.remove('fading');
22215          }, 720);
22216        }
22217        setInterval(function() {
22218          chips.forEach(function(chip, i) { advance(i); });
22219        }, 6000);
22220      })();
22221      (function cardLiveData() {
22222        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22223          var el = document.getElementById('acp-scan-stat');
22224          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22225        }).catch(function(){});
22226        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22227          var el = document.getElementById('acp-test-stat');
22228          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22229        }).catch(function(){});
22230        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22231          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22232          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22233          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22234          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22235          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22236          var stat = document.getElementById('acp-int-stat');
22237          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22238        }).catch(function(){});
22239        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22240          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22241        }).catch(function(){});
22242      })();
22243    })();
22244  </script>
22245  <script nonce="{{ csp_nonce }}">
22246  (function(){
22247    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'}];
22248    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);});}
22249    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22250    function init(){
22251      var btn=document.getElementById('settings-btn');if(!btn)return;
22252      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22253      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>';
22254      document.body.appendChild(m);
22255      var g=document.getElementById('scheme-grid');
22256      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);});
22257      var cl=document.getElementById('settings-close');
22258      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);});})();
22259      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');});
22260      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22261      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22262    }
22263    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22264  }());
22265  </script>
22266  <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>
22267</body>
22268</html>
22269"##,
22270    ext = "html"
22271)]
22272struct SplashTemplate {
22273    csp_nonce: String,
22274    server_mode: bool,
22275    lan_ip: Option<String>,
22276    port: u16,
22277    version: &'static str,
22278    has_api_key: bool,
22279}
22280
22281// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
22282
22283#[derive(Template)]
22284#[template(
22285    source = r##"
22286<!doctype html>
22287<html lang="en">
22288<head>
22289  <meta charset="utf-8">
22290  <meta name="viewport" content="width=device-width, initial-scale=1">
22291  <title>OxideSLOC — Start a Scan</title>
22292  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22293  <style nonce="{{ csp_nonce }}">
22294    :root {
22295      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22296      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22297      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22298      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22299      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22300    }
22301    body.dark-theme {
22302      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22303      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22304    }
22305    *{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;}
22306    .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);}
22307    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22308    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22309    .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));}
22310    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22311    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22312    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22313    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22314    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22315    @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; } }
22316    .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;}
22317    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22318    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22319    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22320    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22321    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22322    .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;}
22323    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22324    .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);}
22325    .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;}
22326    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22327    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22328    .settings-modal-body{padding:14px 16px 16px;}
22329    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22330    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22331    .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;}
22332    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22333    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22334    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22335    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22336    .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;}
22337    .tz-select:focus{border-color:var(--oxide);}
22338    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22339    .page-header{text-align:center;margin-bottom:16px;}
22340    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22341    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22342    /* Cards */
22343    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22344    .option-card-wrap{position:relative;}
22345    .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;}
22346    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22347    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22348    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22349    .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;}
22350    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22351    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22352    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22353    .card-top-row{display:flex;align-items:center;gap:20px;}
22354    /* Two-column layout inside each card */
22355    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22356    .card-left{display:flex;align-items:flex-start;min-width:0;}
22357    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22358    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22359    .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);}
22360    .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);}
22361    .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);}
22362    .card-text{min-width:0;}
22363    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22364    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22365    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22366    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22367    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22368    /* Right CTA column */
22369    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22370    .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;}
22371    /* Re-scan count badge */
22372    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22373    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22374    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22375    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22376    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22377    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22378    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22379    body.dark-theme .btn-secondary{color:var(--oxide);}
22380    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22381    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22382    /* File input overlay — must be full-width so it aligns with other card-right buttons */
22383    .file-input-wrap{position:relative;width:100%;}
22384    .file-input-wrap .btn{width:100%;}
22385    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22386    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22387    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22388    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22389    .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;}
22390    @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));}}
22391    /* Recent list (card 3 — full-width section below header) */
22392    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22393    .recent-list{display:flex;flex-direction:column;gap:8px;}
22394    .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;}
22395    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22396    .recent-item-info{flex:1;min-width:0;}
22397    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22398    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22399    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22400    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22401    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22402    .site-footer a{color:var(--muted);}
22403    @media(max-width:680px){
22404      .card-body{grid-template-columns:1fr;}
22405      .card-right{flex-direction:row;flex-wrap:wrap;}
22406      .btn{flex:1;}
22407    }
22408    .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;}
22409    .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;}
22410    .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;}
22411  </style>
22412</head>
22413<body>
22414  <div class="background-watermarks" aria-hidden="true">
22415    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22416    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22417    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22418    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22419    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22420    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22421    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22422  </div>
22423  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22424  <div class="top-nav">
22425    <div class="top-nav-inner">
22426      <a class="brand" href="/">
22427        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22428        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22429      </a>
22430      <div class="nav-right">
22431        <a class="nav-pill" href="/">Home</a>
22432        <div class="nav-dropdown">
22433          <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>
22434          <div class="nav-dropdown-menu">
22435            <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>
22436          </div>
22437        </div>
22438        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22439        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22440        <div class="nav-dropdown">
22441          <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>
22442          <div class="nav-dropdown-menu">
22443            <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>
22444          </div>
22445        </div>
22446        <div class="server-status-wrap" id="server-status-wrap">
22447          <div class="nav-pill server-online-pill" id="server-status-pill">
22448            <span class="status-dot" id="status-dot"></span>
22449            <span id="server-status-label">Server</span>
22450            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22451          </div>
22452          <div class="server-status-tip">
22453            OxideSLOC is running — accessible on your network.
22454            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22455          </div>
22456        </div>
22457        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22458          <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>
22459        </button>
22460        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22461          <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>
22462          <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>
22463        </button>
22464      </div>
22465    </div>
22466  </div>
22467
22468  <div class="page">
22469    <div class="page-header">
22470      <h1>How would you like to scan?</h1>
22471      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22472    </div>
22473
22474    <div class="option-grid">
22475
22476      <!-- Option 1: New scan -->
22477      <div class="option-card-wrap">
22478        <div class="option-card">
22479        <div class="option-icon new-scan">
22480          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22481        </div>
22482        <div class="card-body">
22483          <div class="card-left">
22484            <div class="card-text">
22485              <div class="option-title">Start a new scan</div>
22486              <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>
22487              <ul class="feature-list">
22488                <li>Live project scope preview before you run</li>
22489                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22490                <li>HTML, PDF, and JSON output — your choice</li>
22491              </ul>
22492            </div>
22493          </div>
22494          <div class="card-right">
22495            <a class="btn btn-primary" href="/scan">
22496              Configure &amp; scan
22497              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22498            </a>
22499            <p class="card-tip">Full 4-step setup · all options</p>
22500          </div>
22501        </div>
22502        </div>
22503      </div>
22504
22505      <!-- Option 2: Load from config file -->
22506      <div class="option-card-wrap">
22507        <div class="option-card">
22508        <div class="option-icon load-config">
22509          <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>
22510        </div>
22511        <div class="card-body">
22512          <div class="card-left">
22513            <div class="card-text">
22514              <div class="option-title">Load a saved config</div>
22515              <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>
22516              <ul class="feature-list">
22517                <li>All 15 settings restored from the file</li>
22518                <li>Fully editable — change path or output dir</li>
22519                <li>Works with any scan-config.json</li>
22520              </ul>
22521            </div>
22522          </div>
22523          <div class="card-right">
22524            <div class="file-input-wrap">
22525              <button class="btn btn-secondary" id="load-config-btn" type="button">
22526                <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>
22527                Choose config file
22528              </button>
22529              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22530            </div>
22531            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22532          </div>
22533        </div>
22534        </div>
22535      </div>
22536
22537      <!-- Option 3: Re-scan recent project -->
22538      <div class="option-card-wrap">
22539        <div class="option-card" id="recent-card">
22540        <div class="card-top-row">
22541          <div class="option-icon rescan">
22542            <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>
22543          </div>
22544          <div class="card-body">
22545            <div class="card-left">
22546              <div class="card-text">
22547                <div class="option-title">Re-scan a recent project</div>
22548                <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>
22549                <ul class="feature-list">
22550                  <li>All 15+ settings restored from the saved config</li>
22551                  <li>Path and output dir are editable before running</li>
22552                  <li>Only scans with a saved config appear here</li>
22553                </ul>
22554              </div>
22555            </div>
22556            <div class="card-right">
22557              <div class="rescan-count-box">
22558                <div class="rescan-count-num" id="rescan-count-num">—</div>
22559                <div class="rescan-count-label">saved configs</div>
22560              </div>
22561              <a class="btn btn-secondary" href="/view-reports">
22562                <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>
22563                View all runs
22564              </a>
22565              <p class="card-tip">Opens run history</p>
22566            </div>
22567          </div>
22568        </div>
22569        <div class="section-divider"></div>
22570        <div class="recent-list" id="recent-list">
22571          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22572        </div>
22573        </div>
22574      </div>
22575
22576    </div>
22577  </div>
22578
22579  <footer class="site-footer">
22580    local code analysis - metrics, history and reports
22581    &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>
22582    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22583    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22584    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22585    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22586  </footer>
22587
22588  <script nonce="{{ csp_nonce }}">
22589    (function () {
22590      var storageKey = 'oxide-sloc-theme';
22591      var body = document.body;
22592      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22593      var toggle = document.getElementById('theme-toggle');
22594      if (toggle) toggle.addEventListener('click', function () {
22595        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22596        body.classList.toggle('dark-theme', next === 'dark');
22597        try { localStorage.setItem(storageKey, next); } catch(e) {}
22598      });
22599
22600      (function randomizeWatermarks() {
22601        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22602        if (!wms.length) return;
22603        var placed = [];
22604        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; }
22605        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]; }
22606        var half = Math.floor(wms.length / 2);
22607        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; });
22608      })();
22609      (function spawnCodeParticles() {
22610        var container = document.getElementById('code-particles');
22611        if (!container) return;
22612        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'];
22613        var count = 38;
22614        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); }
22615      })();
22616      // Recent scans data injected from server
22617      var recentScans = {{ recent_scans_json|safe }};
22618
22619      function configToParams(cfg) {
22620        var p = new URLSearchParams();
22621        p.set('prefilled', '1');
22622        if (cfg.path) p.set('path', cfg.path);
22623        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22624        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22625        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22626        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22627        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22628        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22629        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22630        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22631        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22632        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22633        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22634        if (cfg.report_title) p.set('report_title', cfg.report_title);
22635        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22636        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22637        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22638        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22639        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22640        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22641        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22642        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22643        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22644        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22645        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22646        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22647        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22648        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22649        return p;
22650      }
22651
22652      // Build recent scan list (capped at 3 visible entries)
22653      var list = document.getElementById('recent-list');
22654      var noNote = document.getElementById('no-recent-note');
22655      var hasAny = false;
22656      var MAX_RECENT = 3;
22657      if (Array.isArray(recentScans)) {
22658        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22659        var shown = 0;
22660        validEntries.forEach(function (entry) {
22661          if (shown >= MAX_RECENT) return;
22662          shown++;
22663          hasAny = true;
22664          var item = document.createElement('div');
22665          item.className = 'recent-item';
22666          item.title = 'Restore all settings and open wizard';
22667          item.innerHTML =
22668            '<div class="recent-item-info">' +
22669              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
22670              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
22671            '</div>' +
22672            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
22673          item.addEventListener('click', function () {
22674            var params = configToParams(entry.config);
22675            window.location.href = '/scan?' + params.toString();
22676          });
22677          list.appendChild(item);
22678        });
22679        if (validEntries.length > MAX_RECENT) {
22680          var moreEl = document.createElement('div');
22681          moreEl.className = 'recent-more-link';
22682          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
22683          list.appendChild(moreEl);
22684        }
22685      }
22686      if (hasAny && noNote) noNote.style.display = 'none';
22687      // Update count badge
22688      var countEl = document.getElementById('rescan-count-num');
22689      if (countEl) {
22690        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
22691        countEl.textContent = total > 0 ? total : '0';
22692      }
22693
22694      // Config file loader
22695      var fileInput = document.getElementById('config-file-input');
22696      var fileName = document.getElementById('config-file-name');
22697      var loadBtn = document.getElementById('load-config-btn');
22698      // Wire the visible button to open the hidden file picker.
22699      if (loadBtn && fileInput) {
22700        loadBtn.addEventListener('click', function () { fileInput.click(); });
22701      }
22702      if (fileInput) {
22703        fileInput.addEventListener('change', function () {
22704          var file = fileInput.files && fileInput.files[0];
22705          if (!file) return;
22706          if (fileName) fileName.textContent = '\u2713 ' + file.name;
22707          var reader = new FileReader();
22708          reader.onload = function (e) {
22709            try {
22710              var cfg = JSON.parse(e.target.result);
22711              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
22712              var params = configToParams(cfg);
22713              window.location.href = '/scan?' + params.toString();
22714            } catch (err) {
22715              alert('Could not parse config file: ' + err.message);
22716            }
22717          };
22718          reader.readAsText(file);
22719        });
22720      }
22721
22722      function escHtml(s) {
22723        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
22724      }
22725    })();
22726  </script>
22727  <script nonce="{{ csp_nonce }}">
22728  (function(){
22729    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'}];
22730    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);});}
22731    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22732    function init(){
22733      var btn=document.getElementById('settings-btn');if(!btn)return;
22734      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22735      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>';
22736      document.body.appendChild(m);
22737      var g=document.getElementById('scheme-grid');
22738      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);});
22739      var cl=document.getElementById('settings-close');
22740      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);});})();
22741      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');});
22742      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22743      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22744    }
22745    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22746  }());
22747  </script>
22748  <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]';
22749  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;}
22750  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>
22751</body>
22752</html>
22753"##,
22754    ext = "html"
22755)]
22756struct ScanSetupTemplate {
22757    version: &'static str,
22758    recent_scans_json: String,
22759    csp_nonce: String,
22760}
22761
22762#[derive(Template)]
22763#[template(
22764    source = r##"
22765<!doctype html>
22766<html lang="en">
22767<head>
22768  <meta charset="utf-8">
22769  <meta name="viewport" content="width=device-width, initial-scale=1">
22770  <title>OxideSLOC | {{ report_title }} | Report</title>
22771  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22772  <style nonce="{{ csp_nonce }}">
22773    :root {
22774      --radius: 18px;
22775      --bg: #f5efe8;
22776      --surface: rgba(255,255,255,0.82);
22777      --surface-2: #fbf7f2;
22778      --surface-3: #efe6dc;
22779      --line: #e6d0bf;
22780      --line-strong: #dcb89f;
22781      --text: #43342d;
22782      --muted: #7b675b;
22783      --muted-2: #a08777;
22784      --nav: #b85d33;
22785      --nav-2: #7a371b;
22786      --accent: #6f9bff;
22787      --accent-2: #4a78ee;
22788      --oxide: #d37a4c;
22789      --oxide-2: #b35428;
22790      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
22791      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
22792      --success-bg: #e8f5ed;
22793      --success-text: #1a8f47;
22794      --info-bg: #eef3ff;
22795      --info-text: #4467d8;
22796    }
22797
22798    body.dark-theme {
22799      --bg: #1b1511;
22800      --surface: #261c17;
22801      --surface-2: #2d221d;
22802      --surface-3: #372922;
22803      --line: #524238;
22804      --line-strong: #6c5649;
22805      --text: #f5ece6;
22806      --muted: #c7b7aa;
22807      --muted-2: #aa9485;
22808      --nav: #b85d33;
22809      --nav-2: #7a371b;
22810      --accent: #6f9bff;
22811      --accent-2: #4a78ee;
22812      --oxide: #d37a4c;
22813      --oxide-2: #b35428;
22814      --shadow: 0 18px 42px rgba(0,0,0,0.28);
22815      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
22816      --success-bg: #163927;
22817      --success-text: #8fe2a8;
22818      --info-bg: #1c2847;
22819      --info-text: #a9c1ff;
22820    }
22821
22822    * { box-sizing: border-box; }
22823    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); }
22824    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
22825    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
22826    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
22827    .top-nav, .page { position: relative; z-index: 2; }
22828    .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); }
22829    .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; }
22830    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
22831    .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)); }
22832    .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; }
22833    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
22834    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
22835    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
22836    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
22837    .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; }
22838    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
22839    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22840    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
22841    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22842    @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; } }
22843    .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; }
22844    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
22845    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
22846    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
22847    .theme-toggle .icon-sun { display:none; }
22848    body.dark-theme .theme-toggle .icon-sun { display:block; }
22849    body.dark-theme .theme-toggle .icon-moon { display:none; }
22850    .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;}
22851    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22852    .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);}
22853    .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;}
22854    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22855    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22856    .settings-modal-body{padding:14px 16px 16px;}
22857    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22858    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22859    .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;}
22860    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22861    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22862    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22863    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22864    .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;}
22865    .tz-select:focus{border-color:var(--oxide);}
22866    .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; }
22867    .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;}
22868    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
22869    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
22870    .hero, .panel { padding: 22px; }
22871    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
22872    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
22873    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
22874    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
22875    .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; }
22876    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
22877    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
22878    .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; }
22879    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
22880    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
22881    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
22882    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
22883    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
22884    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
22885    .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); }
22886    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
22887    .delta-card-val { font-size:16px; font-weight:800; }
22888    .delta-card-val.pos { color:#1e7e34; }
22889    .delta-card-val.neg { color:var(--neg); }
22890    .delta-card-val.mod { color:#b35428; }
22891    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
22892    .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; }
22893    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22894    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
22895    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
22896    .compare-ts { font-size:13px; color:var(--muted); }
22897    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
22898    .compare-arrow { color: var(--muted); }
22899    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
22900    .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; }
22901    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
22902    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
22903    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
22904    .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; }
22905    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
22906    .run-mgmt-card .action-buttons { justify-content:center; }
22907    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
22908    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
22909    .button, .copy-button {
22910      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;
22911    }
22912    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
22913    @keyframes spin { to { transform: rotate(360deg); } }
22914    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
22915    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
22916    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
22917    .path-item strong { display: block; margin-bottom: 6px; }
22918    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
22919    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
22920    .path-subitem { flex: 1; }
22921    .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); }
22922    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); }
22923    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
22924    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
22925    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
22926    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
22927    th { color: var(--muted); font-weight: 700; }
22928    tr:last-child td { border-bottom: none; }
22929    #subm-tbl col:nth-child(1){width:15%;}
22930    #subm-tbl col:nth-child(2){width:31%;}
22931    #subm-tbl col:nth-child(3){width:9%;}
22932    #subm-tbl col:nth-child(4){width:9%;}
22933    #subm-tbl col:nth-child(5){width:9%;}
22934    #subm-tbl col:nth-child(6){width:9%;}
22935    #subm-tbl col:nth-child(7){width:9%;}
22936    #subm-tbl col:nth-child(8){width:9%;}
22937    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
22938    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
22939    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
22940    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
22941    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
22942    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
22943    .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; }
22944    .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; }
22945    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
22946    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
22947    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
22948    .muted { color: var(--muted); }
22949    /* Run-ID chip row (mirrors HTML report) */
22950    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
22951    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
22952    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
22953    .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; }
22954    .run-id-chip[data-copy] { cursor:pointer; }
22955    a.run-id-chip { text-decoration:none; cursor:pointer; }
22956    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
22957    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
22958    .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; }
22959    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
22960    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22961    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
22962    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
22963    a.commit-link-value { color:inherit; text-decoration:none; }
22964    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
22965    .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; }
22966    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22967    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
22968    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
22969    .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; }
22970    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
22971    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
22972    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
22973    /* Meta chips row */
22974    .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%; }
22975    .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; }
22976    .meta-chip:last-child { border-right:none; }
22977    .meta-chip b { color:var(--text); font-weight:700; }
22978    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22979    .site-footer a{color:var(--muted);}
22980    .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; }
22981    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
22982    .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; }
22983    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
22984    /* Stat chips (matches HTML report) */
22985    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
22986    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
22987    /* Hero stat strip: uniform grid where every card is the same width and the
22988       columns line up across both rows. JS sets the column count to ceil(n/2) so
22989       the cards always occupy exactly two rows; when the count is odd the last
22990       card spans two columns to fill the trailing cell with no empty gap. */
22991    .summary-strip-hero { align-items:stretch; }
22992    .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; }
22993    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
22994    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
22995    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
22996    .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; }
22997    .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); }
22998    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
22999    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23000    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23001    .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; }
23002    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23003    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23004    .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); }
23005    .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); }
23006    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23007    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23008    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23009    /* Submodule panel */
23010    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23011    /* Metrics tables stack */
23012    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23013    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23014    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23015    .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)); }
23016    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23017    /* Metrics table */
23018    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23019    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23020    .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; }
23021    .metrics-table thead th:not(:first-child) { text-align: right; }
23022    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23023    .metrics-table tbody tr:last-child td { border-bottom: none; }
23024    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23025    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23026    .metrics-table tbody tr:hover td { background: var(--surface-2); }
23027    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23028    .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; }
23029    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23030    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23031    .mt-val-pos { color: var(--pos); font-weight: 700; }
23032    .mt-val-neg { color: var(--neg); font-weight: 700; }
23033    .mt-val-zero { color: var(--muted); }
23034    .mt-val-mod { color: var(--oxide-2); }
23035    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23036    @media (max-width: 1180px) {
23037      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23038      .nav-project-slot, .nav-status { justify-content:flex-start; }
23039      .hero-top { flex-direction: column; }
23040      .run-mgmt-strip { flex-direction: column; }
23041    }
23042    .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;}
23043    @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));}}
23044    .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;}
23045    /* ── Result-page chart controls ─────────────────────────────────────────── */
23046    .r-chart-section{margin-bottom:24px;}
23047    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23048    .section-pair > .panel{flex-shrink:0;}
23049    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23050    .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;}
23051    .r-chart-select:focus{border-color:var(--accent);}
23052    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23053    .r-chart-container svg{display:block;width:100%;height:auto;}
23054    .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;}
23055    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23056    .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;}
23057    .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);}
23058    .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;}
23059    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23060    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23061    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23062    .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;}
23063    .r-chart-modal-close:hover{opacity:.7;}
23064    body.dark-theme .r-chart-modal{background:var(--surface);}
23065    .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;}
23066    .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);}
23067    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23068    .lang-bar-row:hover{transform:translateY(-2px);}
23069    .lang-bar-row .rchit:hover{filter:none;transform:none;}
23070    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23071    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23072    .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;}
23073    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23074    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23075    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23076    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23077    #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;}
23078    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23079    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23080    .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;}
23081    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23082    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23083    .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;}
23084    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23085    .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;}
23086    .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;}
23087    body.has-report-banner .top-nav{top:27px;}
23088    body.has-report-banner{padding-bottom:27px;}
23089  </style>
23090</head>
23091<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23092  <div class="background-watermarks" aria-hidden="true">
23093    <img src="/images/logo/logo-text.png" alt="" />
23094    <img src="/images/logo/logo-text.png" alt="" />
23095    <img src="/images/logo/logo-text.png" alt="" />
23096    <img src="/images/logo/logo-text.png" alt="" />
23097    <img src="/images/logo/logo-text.png" alt="" />
23098    <img src="/images/logo/logo-text.png" alt="" />
23099    <img src="/images/logo/logo-text.png" alt="" />
23100    <img src="/images/logo/logo-text.png" alt="" />
23101    <img src="/images/logo/logo-text.png" alt="" />
23102    <img src="/images/logo/logo-text.png" alt="" />
23103    <img src="/images/logo/logo-text.png" alt="" />
23104    <img src="/images/logo/logo-text.png" alt="" />
23105    <img src="/images/logo/logo-text.png" alt="" />
23106    <img src="/images/logo/logo-text.png" alt="" />
23107  </div>
23108  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23109  {% if let Some(banner) = report_header_footer %}
23110  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23111  {% endif %}
23112  <div class="top-nav">
23113    <div class="top-nav-inner">
23114      <a class="brand" href="/">
23115        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23116        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23117      </a>
23118      <div class="nav-project-slot">
23119        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23120      </div>
23121      <div class="nav-status">
23122        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23123        <div class="nav-dropdown">
23124          <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>
23125          <div class="nav-dropdown-menu">
23126            <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>
23127          </div>
23128        </div>
23129        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23130        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23131        <div class="nav-dropdown">
23132          <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>
23133          <div class="nav-dropdown-menu">
23134            <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>
23135          </div>
23136        </div>
23137        <div class="server-status-wrap" id="server-status-wrap">
23138          <div class="nav-pill server-online-pill" id="server-status-pill">
23139            <span class="status-dot" id="status-dot"></span>
23140            <span id="server-status-label">Server</span>
23141            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23142          </div>
23143          <div class="server-status-tip">
23144            OxideSLOC is running — accessible on your network.
23145            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23146          </div>
23147        </div>
23148        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23149          <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>
23150        </button>
23151        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23152          <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>
23153          <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>
23154        </button>
23155      </div>
23156    </div>
23157  </div>
23158
23159  <div class="page">
23160    <section class="hero">
23161      <div class="hero-top">
23162        <div>
23163          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23164            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23165            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23166            <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>
23167          </div>
23168        </div>
23169        <div class="hero-quick-actions">
23170          {% if server_mode %}
23171          <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>
23172          {% else %}
23173          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23174          {% endif %}
23175          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23176          {% if !server_mode %}
23177          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23178          {% endif %}
23179          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23180          <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>
23181        </div>
23182      </div>
23183
23184      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23185      <div class="run-id-row">
23186        <span class="run-id-chip" data-copy="{{ run_id }}">
23187          <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>
23188          <span class="run-id-chip-value">{{ run_id }}</span>
23189          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23190        </span>
23191        {% match git_commit_long %}
23192          {% when Some with (long_sha) %}
23193          {% match git_commit_url %}
23194            {% when Some with (commit_url) %}
23195            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23196              <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>
23197              <span class="run-id-chip-value">{{ long_sha }}</span>
23198              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23199            </a>
23200            {% when None %}
23201            <span class="run-id-chip" data-copy="{{ long_sha }}">
23202              <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>
23203              <span class="run-id-chip-value">{{ long_sha }}</span>
23204              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23205            </span>
23206          {% endmatch %}
23207          {% when None %}
23208          <span class="run-id-chip muted-chip">
23209            <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>
23210            <span class="run-id-chip-value">Not detected</span>
23211            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23212          </span>
23213        {% endmatch %}
23214        {% match git_branch %}
23215          {% when Some with (branch) %}
23216          {% match git_branch_url %}
23217            {% when Some with (branch_url) %}
23218            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23219              <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>
23220              <span class="run-id-chip-value">{{ branch }}</span>
23221              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23222            </a>
23223            {% when None %}
23224            <span class="run-id-chip">
23225              <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>
23226              <span class="run-id-chip-value">{{ branch }}</span>
23227              <span class="chip-tooltip">Git branch active at scan time</span>
23228            </span>
23229          {% endmatch %}
23230          {% when None %}
23231          <span class="run-id-chip muted-chip">
23232            <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>
23233            <span class="run-id-chip-value">Not detected</span>
23234            <span class="chip-tooltip">No Git branch was found for this scan</span>
23235          </span>
23236        {% endmatch %}
23237        {% match git_author %}
23238          {% when Some with (author) %}
23239          <span class="run-id-chip" data-author="{{ author }}">
23240            <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>
23241            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23242            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23243          </span>
23244          {% when None %}
23245          <span class="run-id-chip muted-chip">
23246            <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>
23247            <span class="run-id-chip-value">Not detected</span>
23248            <span class="chip-tooltip">No commit author was found for this scan</span>
23249          </span>
23250        {% endmatch %}
23251      </div>
23252
23253      <!-- Scan metadata row -->
23254      <div class="meta">
23255        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23256        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23257        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23258        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23259        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23260      </div>
23261
23262      <!-- All summary stat chips in one unified strip (8 columns) -->
23263      <div class="summary-strip summary-strip-hero">
23264        <div class="stat-chip" data-raw="{{ physical_lines }}">
23265          <div class="stat-chip-label">Physical lines</div>
23266          <div class="stat-chip-val">{{ physical_lines }}</div>
23267          <div class="stat-chip-exact"></div>
23268          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23269        </div>
23270        <div class="stat-chip" data-raw="{{ code_lines }}">
23271          <div class="stat-chip-label">Code</div>
23272          <div class="stat-chip-val">{{ code_lines }}</div>
23273          <div class="stat-chip-exact"></div>
23274          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23275        </div>
23276        <div class="stat-chip" data-raw="{{ comment_lines }}">
23277          <div class="stat-chip-label">Comments</div>
23278          <div class="stat-chip-val">{{ comment_lines }}</div>
23279          <div class="stat-chip-exact"></div>
23280          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23281        </div>
23282        <div class="stat-chip" data-raw="{{ blank_lines }}">
23283          <div class="stat-chip-label">Blank</div>
23284          <div class="stat-chip-val">{{ blank_lines }}</div>
23285          <div class="stat-chip-exact"></div>
23286          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23287        </div>
23288        <div class="stat-chip" data-raw="{{ mixed_lines }}">
23289          <div class="stat-chip-label">Mixed separate</div>
23290          <div class="stat-chip-val">{{ mixed_lines }}</div>
23291          <div class="stat-chip-exact"></div>
23292          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23293        </div>
23294        <div class="stat-chip" data-raw="{{ functions }}">
23295          <div class="stat-chip-label">Functions</div>
23296          <div class="stat-chip-val">{{ functions }}</div>
23297          <div class="stat-chip-exact"></div>
23298          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23299        </div>
23300        <div class="stat-chip" data-raw="{{ classes }}">
23301          <div class="stat-chip-label">Classes / Types</div>
23302          <div class="stat-chip-val">{{ classes }}</div>
23303          <div class="stat-chip-exact"></div>
23304          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23305        </div>
23306        <div class="stat-chip" data-raw="{{ variables }}">
23307          <div class="stat-chip-label">Variables</div>
23308          <div class="stat-chip-val">{{ variables }}</div>
23309          <div class="stat-chip-exact"></div>
23310          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23311        </div>
23312        <div class="stat-chip" data-raw="{{ imports }}">
23313          <div class="stat-chip-label">Imports</div>
23314          <div class="stat-chip-val">{{ imports }}</div>
23315          <div class="stat-chip-exact"></div>
23316          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23317        </div>
23318        <div class="stat-chip" data-raw="{{ test_count }}">
23319          <div class="stat-chip-label">Tests</div>
23320          <div class="stat-chip-val">{{ test_count }}</div>
23321          <div class="stat-chip-exact"></div>
23322          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23323        </div>
23324        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23325          <div class="stat-chip-label">Code density</div>
23326          <div class="stat-chip-val stat-chip-density-val">—</div>
23327          <div class="stat-chip-exact"></div>
23328          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23329        </div>
23330        <div class="stat-chip" data-raw="{{ files_analyzed }}">
23331          <div class="stat-chip-label">Files analyzed</div>
23332          <div class="stat-chip-val">{{ files_analyzed }}</div>
23333          <div class="stat-chip-exact"></div>
23334          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23335        </div>
23336        {% if cyclomatic_complexity > 0 %}
23337        <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 %}>
23338          <div class="stat-chip-label">Complexity score</div>
23339          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23340          <div class="stat-chip-exact"></div>
23341          <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>
23342        </div>
23343        {% endif %}
23344        {% if let Some(ls) = lsloc %}
23345        <div class="stat-chip" data-raw="{{ ls }}">
23346          <div class="stat-chip-label">Logical SLOC</div>
23347          <div class="stat-chip-val">{{ ls }}</div>
23348          <div class="stat-chip-exact"></div>
23349          <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>
23350        </div>
23351        {% endif %}
23352        {% if uloc > 0 %}
23353        <div class="stat-chip" data-raw="{{ uloc }}">
23354          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23355          <div class="stat-chip-val">{{ uloc }}</div>
23356          <div class="stat-chip-exact"></div>
23357          <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>
23358        </div>
23359        {% endif %}
23360        {% if uloc > 0 && dryness_pct_str != "" %}
23361        <div class="stat-chip">
23362          <div class="stat-chip-label">DRYness</div>
23363          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23364          <div class="stat-chip-exact"></div>
23365          <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>
23366        </div>
23367        {% endif %}
23368        {% if duplicate_group_count > 0 %}
23369        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23370          <div class="stat-chip-label">Duplicate groups</div>
23371          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23372          <div class="stat-chip-exact"></div>
23373          <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>
23374        </div>
23375        {% endif %}
23376        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23377             odd, so the strip always forms exactly two full rows with every column
23378             aligned and every card the same width (no oversized card, no gap). -->
23379        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23380          <div class="stat-chip-label">Assertions</div>
23381          <div class="stat-chip-val">{{ test_assertion_count }}</div>
23382          <div class="stat-chip-exact"></div>
23383          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23384        </div>
23385      </div>
23386
23387      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23388      <div class="compare-banner">
23389        <div class="compare-banner-body">
23390          <div class="compare-banner-top">
23391          <div class="compare-banner-meta">
23392            <span class="compare-label">Previous scan</span>
23393            <span class="compare-ts">{{ prev_ts }}</span>
23394            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23395            {% if let Some(prev_code) = prev_run_code_lines %}
23396            <div class="compare-banner-stats" style="margin-top:4px;">
23397              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23398              <span class="compare-arrow">→</span>
23399              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23400              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23401              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23402            </div>
23403            {% endif %}
23404          </div>
23405          {% if delta_lines_added.is_some() %}
23406          <div class="delta-cards-inline">
23407            <div class="delta-card-inline">
23408              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23409              <div class="delta-card-lbl">lines added</div>
23410              <div class="delta-card-tip">Code lines added since the previous scan</div>
23411            </div>
23412            <div class="delta-card-inline">
23413              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23414              <div class="delta-card-lbl">lines removed</div>
23415              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23416            </div>
23417            <div class="delta-card-inline">
23418              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23419              <div class="delta-card-lbl">unmodified lines</div>
23420              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23421            </div>
23422            <div class="delta-card-inline">
23423              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23424              <div class="delta-card-lbl">files modified</div>
23425              <div class="delta-card-tip">Files with at least one line changed</div>
23426            </div>
23427            <div class="delta-card-inline">
23428              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23429              <div class="delta-card-lbl">files added</div>
23430              <div class="delta-card-tip">New files added since the previous scan</div>
23431            </div>
23432            <div class="delta-card-inline">
23433              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23434              <div class="delta-card-lbl">files removed</div>
23435              <div class="delta-card-tip">Files deleted since the previous scan</div>
23436            </div>
23437            <div class="delta-card-inline">
23438              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23439              <div class="delta-card-lbl">files unchanged</div>
23440              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23441            </div>
23442          </div>
23443          {% else %}
23444          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23445            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23446          </p>
23447          {% endif %}
23448          </div>
23449          <div class="compare-banner-actions">
23450            <div class="compare-banner-actions-left">
23451              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23452              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23453            </div>
23454            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23455          </div>
23456        </div>
23457      </div>
23458      {% endif %}{% endif %}
23459
23460      <div class="action-grid">
23461        <div class="action-card">
23462          <h3>HTML report</h3>
23463          <div class="action-buttons">
23464            {% match html_url %}
23465              {% when Some with (url) %}
23466                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23467              {% when None %}{% endmatch %}
23468            {% match html_download_url %}
23469              {% when Some with (url) %}
23470                <a class="button secondary" href="{{ url }}">Download HTML</a>
23471              {% when None %}{% endmatch %}
23472            {% match html_path %}
23473              {% when Some with (_path) %}{% when None %}{% endmatch %}
23474            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23475          </div>
23476        </div>
23477        <div class="action-card">
23478          <h3>PDF report</h3>
23479          <div class="action-buttons">
23480            {% match pdf_url %}
23481              {% when Some with (url) %}
23482                {% if pdf_generating %}
23483                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23484                    <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>
23485                    Generating PDF…
23486                  </button>
23487                {% else %}
23488                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23489                {% endif %}
23490              {% when None %}
23491                {% match html_url %}
23492                  {% when Some with (_hurl) %}
23493                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23494                    <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>
23495                  {% when None %}
23496                    <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;">
23497                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23498                    </p>
23499                {% endmatch %}
23500            {% endmatch %}
23501            {% match pdf_download_url %}
23502              {% when Some with (url) %}
23503                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23504              {% when None %}{% endmatch %}
23505            {% match pdf_url %}
23506              {% when Some with (_) %}
23507                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23508              {% when None %}{% endmatch %}
23509          </div>
23510        </div>
23511        <div class="action-card">
23512          <h3>JSON result</h3>
23513          <div class="action-buttons">
23514            {% match json_url %}
23515              {% when Some with (url) %}
23516                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23517              {% when None %}{% endmatch %}
23518            {% match json_download_url %}
23519              {% when Some with (url) %}
23520                <a class="button secondary" href="{{ url }}">Download JSON</a>
23521              {% when None %}{% endmatch %}
23522            {% match json_path %}
23523              {% when Some with (_path) %}
23524                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23525              {% when None %}
23526                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23527              {% endmatch %}
23528          </div>
23529        </div>
23530        <div class="action-card">
23531          <h3>Scan config</h3>
23532          <div class="action-buttons">
23533            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23534            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23535            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23536          </div>
23537        </div>
23538        {% if confluence_configured %}
23539        <div class="action-card" id="confluenceCard">
23540          <h3>Confluence</h3>
23541          <div class="action-buttons">
23542            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23543            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23544          </div>
23545          <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>
23546        </div>
23547        {% endif %}
23548      </div>
23549      {% if confluence_configured %}
23550      <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;">
23551        <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);">
23552          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23553          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23554          <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;">
23555          <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>
23556          <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;">
23557          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23558          <div style="display:flex;gap:10px;justify-content:flex-end;">
23559            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23560            <button class="button" id="confSubmitBtn" type="button">Post</button>
23561          </div>
23562        </div>
23563      </div>
23564      {% endif %}
23565      <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;">
23566        <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);">
23567          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23568          <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>
23569          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23570          <div style="display:flex;gap:18px;justify-content:flex-end;">
23571            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23572            <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>
23573          </div>
23574        </div>
23575      </div>
23576      {% if !submodule_rows.is_empty() %}
23577      <div class="submodule-panel">
23578        <div class="toolbar-row">
23579          <div>
23580            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23581            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23582          </div>
23583          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23584        </div>
23585        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23586        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23587          <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>
23588          <thead>
23589            <tr>
23590              <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>
23591              <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>
23592              <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>
23593              <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>
23594              <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>
23595              <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>
23596              <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>
23597              <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>
23598            </tr>
23599          </thead>
23600          <tbody>
23601            {% for row in submodule_rows %}
23602            <tr>
23603              <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>
23604              <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>
23605              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23606              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23607              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23608              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23609              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23610              <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>
23611            </tr>
23612            {% endfor %}
23613          </tbody>
23614        </table>
23615        </div>
23616      </div>
23617      {% endif %}
23618
23619      <div class="metrics-tables-stack">
23620
23621        <div class="metrics-table-wrap">
23622          <div class="metrics-table-title">Files</div>
23623          <table class="metrics-table">
23624            <thead>
23625              <tr>
23626                <th>Metric</th>
23627                <th>This Run</th>
23628                <th>Previous</th>
23629                <th>Change</th>
23630              </tr>
23631            </thead>
23632            <tbody>
23633              <tr>
23634                <td>Files analyzed</td>
23635                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23636                <td>{{ prev_fa_str|commas }}</td>
23637                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23638              </tr>
23639              <tr>
23640                <td>Files skipped</td>
23641                <td>{{ files_skipped|commas }}</td>
23642                <td>{{ prev_fs_str|commas }}</td>
23643                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23644              </tr>
23645              <tr>
23646                <td>Files modified</td>
23647                <td class="mt-val-na">—</td>
23648                <td class="mt-val-na">—</td>
23649                <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>
23650              </tr>
23651              <tr>
23652                <td>Files unchanged</td>
23653                <td class="mt-val-na">—</td>
23654                <td class="mt-val-na">—</td>
23655                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23656              </tr>
23657            </tbody>
23658          </table>
23659        </div>
23660
23661        <div class="metrics-table-wrap">
23662          <div class="metrics-table-title">Line Counts</div>
23663          <table class="metrics-table">
23664            <thead>
23665              <tr>
23666                <th>Metric</th>
23667                <th>This Run</th>
23668                <th>Previous</th>
23669                <th>Change</th>
23670              </tr>
23671            </thead>
23672            <tbody>
23673              <tr>
23674                <td>Physical lines</td>
23675                <td class="mt-val-large">{{ physical_lines|commas }}</td>
23676                <td>{{ prev_pl_str|commas }}</td>
23677                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
23678              </tr>
23679              <tr>
23680                <td>Code lines</td>
23681                <td class="mt-val-large">{{ code_lines|commas }}</td>
23682                <td>{{ prev_cl_str|commas }}</td>
23683                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
23684              </tr>
23685              <tr>
23686                <td>Comment lines</td>
23687                <td>{{ comment_lines|commas }}</td>
23688                <td>{{ prev_cml_str|commas }}</td>
23689                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
23690              </tr>
23691              <tr>
23692                <td>Blank lines</td>
23693                <td>{{ blank_lines|commas }}</td>
23694                <td>{{ prev_bl_str|commas }}</td>
23695                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
23696              </tr>
23697              <tr>
23698                <td>Mixed (separate)</td>
23699                <td>{{ mixed_lines|commas }}</td>
23700                <td class="mt-val-na">—</td>
23701                <td class="mt-val-na">—</td>
23702              </tr>
23703            </tbody>
23704          </table>
23705        </div>
23706
23707        <div class="metrics-tables-lower">
23708          <div class="metrics-table-wrap">
23709            <div class="metrics-table-title">Code Structure</div>
23710            <table class="metrics-table">
23711              <thead>
23712                <tr>
23713                  <th>Metric</th>
23714                  <th>This Run</th>
23715                </tr>
23716              </thead>
23717              <tbody>
23718                <tr>
23719                  <td>Functions</td>
23720                  <td>{{ functions|commas }}</td>
23721                </tr>
23722                <tr>
23723                  <td>Classes / Types</td>
23724                  <td>{{ classes|commas }}</td>
23725                </tr>
23726                <tr>
23727                  <td>Variables</td>
23728                  <td>{{ variables|commas }}</td>
23729                </tr>
23730                <tr>
23731                  <td>Imports</td>
23732                  <td>{{ imports|commas }}</td>
23733                </tr>
23734              </tbody>
23735            </table>
23736          </div>
23737
23738          <div class="metrics-table-wrap">
23739            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
23740            <table class="metrics-table">
23741              <thead>
23742                <tr>
23743                  <th>Metric</th>
23744                  <th>Change</th>
23745                </tr>
23746              </thead>
23747              <tbody>
23748                <tr>
23749                  <td>Lines added</td>
23750                  <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>
23751                </tr>
23752                <tr>
23753                  <td>Lines removed</td>
23754                  <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>
23755                </tr>
23756                <tr>
23757                  <td>Lines modified (net)</td>
23758                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
23759                </tr>
23760                <tr>
23761                  <td>Lines unmodified</td>
23762                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
23763                </tr>
23764              </tbody>
23765            </table>
23766          </div>
23767        </div>
23768
23769      </div>
23770
23771      <div class="path-list">
23772        <div class="path-item">
23773          <div class="path-item-label">Project path</div>
23774          {% 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 %}
23775        </div>
23776        <div class="path-item">
23777          <div class="path-item-label">Git branch</div>
23778          {% if let Some(branch) = git_branch %}
23779          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
23780          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
23781          {% else %}
23782          <code style="color:var(--muted)">—</code>
23783          {% endif %}
23784        </div>
23785        <div class="path-item">
23786          <div class="path-item-label">Output folder</div>
23787          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
23788        </div>
23789        <div class="path-item">
23790          <div class="path-item-label">Run ID</div>
23791          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
23792            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
23793            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
23794          </div>
23795        </div>
23796      </div>
23797    </section>
23798
23799    {% if has_cocomo %}
23800    <div class="cocomo-box" style="margin-top:24px;">
23801      <div class="cocomo-box-head">
23802        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
23803        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23804          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
23805          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
23806        </span>
23807      </div>
23808      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23809        <div class="stat-chip">
23810          <div class="stat-chip-label">Person-months</div>
23811          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
23812          <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>
23813        </div>
23814        <div class="stat-chip">
23815          <div class="stat-chip-label">Schedule (months)</div>
23816          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
23817          <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>
23818        </div>
23819        <div class="stat-chip">
23820          <div class="stat-chip-label">Avg. Team Size</div>
23821          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
23822          <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>
23823        </div>
23824        <div class="stat-chip">
23825          <div class="stat-chip-label">Input KSLOC</div>
23826          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
23827          <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>
23828        </div>
23829      </div>
23830      <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>
23831    </div>
23832    {% endif %}
23833
23834    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
23835    <div class="cocomo-box" style="margin-top:24px;">
23836      <div class="cocomo-box-head">
23837        <span class="cocomo-box-title">Tests &amp; Coverage</span>
23838        {% if has_coverage_data %}
23839        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
23840          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
23841        </span>
23842        {% endif %}
23843      </div>
23844      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
23845        <div class="stat-chip">
23846          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
23847          <div class="stat-chip-label">Test Functions</div>
23848          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
23849        </div>
23850        <div class="stat-chip">
23851          {% if has_coverage_data %}
23852          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
23853          {% else %}
23854          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23855          {% endif %}
23856          <div class="stat-chip-label">Line Coverage</div>
23857          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
23858        </div>
23859        <div class="stat-chip">
23860          {% if !cov_fn_pct.is_empty() %}
23861          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
23862          {% else %}
23863          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23864          {% endif %}
23865          <div class="stat-chip-label">Fn Coverage</div>
23866          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
23867        </div>
23868        <div class="stat-chip">
23869          {% if !cov_branch_pct.is_empty() %}
23870          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
23871          {% else %}
23872          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
23873          {% endif %}
23874          <div class="stat-chip-label">Branch Coverage</div>
23875          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
23876        </div>
23877      </div>
23878      {% if has_coverage_data %}
23879      <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>
23880      {% else %}
23881      <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>
23882      {% endif %}
23883    </div>
23884
23885    <div class="section-pair">
23886    <section class="panel">
23887        <div class="toolbar-row">
23888          <div>
23889            <h2>Language Breakdown</h2>
23890            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
23891          </div>
23892          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23893        </div>
23894        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
23895    </section>
23896
23897    <section class="panel r-chart-section">
23898      <div class="toolbar-row" style="margin-bottom:16px;">
23899        <div>
23900          <h2>Visualizations</h2>
23901          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
23902        </div>
23903      </div>
23904
23905      <div class="r-viz-grid">
23906        <div class="r-viz-card">
23907          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23908            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
23909            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23910          </div>
23911          <div class="r-chart-tab-bar">
23912            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
23913            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
23914          </div>
23915          <div class="r-chart-container" id="r-composition-chart"></div>
23916        </div>
23917        <div class="r-viz-card">
23918          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23919            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
23920            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23921          </div>
23922          <div class="r-chart-container" id="r-scatter-chart"></div>
23923        </div>
23924        {% if has_semantic_data %}
23925        <div class="r-viz-card">
23926          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
23927            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
23928            <select class="r-chart-select" id="r-semantic-metric">
23929              <option value="functions">Functions</option>
23930              <option value="classes">Classes</option>
23931              <option value="variables">Variables</option>
23932              <option value="imports">Imports</option>
23933              <option value="tests">Tests</option>
23934            </select>
23935            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23936          </div>
23937          <div class="r-chart-container" id="r-semantic-chart"></div>
23938        </div>
23939        {% endif %}
23940        <div class="r-viz-card">
23941          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23942            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
23943            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23944          </div>
23945          <div class="r-chart-container" id="r-density-chart"></div>
23946        </div>
23947        <div class="r-viz-card">
23948          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
23949            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
23950            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23951          </div>
23952          <div class="r-chart-container" id="r-avglines-chart"></div>
23953        </div>
23954        <div class="r-viz-card">
23955          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
23956            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
23957            <select class="r-chart-select" id="r-sub-metric">
23958              <option value="code">Code Lines</option>
23959              <option value="comment">Comments</option>
23960              <option value="blank">Blank Lines</option>
23961              <option value="physical">Physical Lines</option>
23962              <option value="files">Files</option>
23963            </select>
23964            <select class="r-chart-select" id="r-sub-sort">
23965              <option value="desc">Value ↓</option>
23966              <option value="asc">Value ↑</option>
23967              <option value="name">Name A→Z</option>
23968            </select>
23969            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
23970          </div>
23971          <div class="r-chart-container" id="r-submodule-chart"></div>
23972        </div>
23973      </div>
23974
23975    </section>
23976    </div>
23977
23978  </div>
23979
23980  <div id="r-tt" aria-hidden="true"></div>
23981
23982  <script nonce="{{ csp_nonce }}">
23983    (function () {
23984      var body = document.body;
23985      var themeToggle = document.getElementById('theme-toggle');
23986      var storageKey = 'oxide-sloc-theme';
23987
23988      function applyTheme(theme) {
23989        body.classList.toggle('dark-theme', theme === 'dark');
23990      }
23991
23992      function loadSavedTheme() {
23993        try {
23994          var saved = localStorage.getItem(storageKey);
23995          if (saved === 'dark' || saved === 'light') {
23996            applyTheme(saved);
23997          }
23998        } catch (e) {}
23999      }
24000
24001      if (themeToggle) {
24002        themeToggle.addEventListener('click', function () {
24003          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24004          applyTheme(nextTheme);
24005          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24006        });
24007      }
24008
24009      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24010        button.addEventListener('click', function () {
24011          var value = button.getAttribute('data-copy-value') || '';
24012          if (!value) return;
24013          var originalText = button.textContent;
24014          function flashSuccess() {
24015            button.textContent = 'Copied!';
24016            setTimeout(function () { button.textContent = originalText; }, 1800);
24017          }
24018          function flashFail() {
24019            button.textContent = 'Copy failed';
24020            setTimeout(function () { button.textContent = originalText; }, 2000);
24021          }
24022          if (navigator.clipboard && navigator.clipboard.writeText) {
24023            navigator.clipboard.writeText(value).then(flashSuccess, function () {
24024              fallbackCopy(value, flashSuccess, flashFail);
24025            });
24026          } else {
24027            fallbackCopy(value, flashSuccess, flashFail);
24028          }
24029        });
24030      });
24031      function fallbackCopy(text, onSuccess, onFail) {
24032        try {
24033          var ta = document.createElement('textarea');
24034          ta.value = text;
24035          ta.style.position = 'fixed';
24036          ta.style.top = '-9999px';
24037          ta.style.left = '-9999px';
24038          document.body.appendChild(ta);
24039          ta.focus();
24040          ta.select();
24041          var ok = document.execCommand('copy');
24042          document.body.removeChild(ta);
24043          if (ok) { onSuccess(); } else { onFail(); }
24044        } catch (e) { onFail(); }
24045      }
24046
24047      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24048        btn.addEventListener('click', function () {
24049          var folder = btn.getAttribute('data-folder') || '';
24050          if (!folder) return;
24051          var orig = btn.textContent;
24052          fetch('/open-path?path=' + encodeURIComponent(folder))
24053            .then(function (r) { return r.json(); })
24054            .then(function (d) {
24055              if (d && d.server_mode_disabled) {
24056                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24057              } else if (d && d.ok) {
24058                btn.textContent = 'Opened!';
24059                setTimeout(function () { btn.textContent = orig; }, 1800);
24060              }
24061            })
24062            .catch(function () {
24063              btn.textContent = 'Failed';
24064              setTimeout(function () { btn.textContent = orig; }, 2000);
24065            });
24066        });
24067      });
24068
24069      loadSavedTheme();
24070
24071      // ── Compact number formatting for stat chips ──────────────────────────
24072      (function(){
24073        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();}
24074        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24075          var raw=parseInt(chip.getAttribute('data-raw'),10);
24076          if(isNaN(raw))return;
24077          var valEl=chip.querySelector('.stat-chip-val');
24078          if(valEl)valEl.textContent=fmt(raw);
24079          var exactEl=chip.querySelector('.stat-chip-exact');
24080          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24081        });
24082        // Code density chip
24083        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24084          var code=parseInt(chip.getAttribute('data-code'),10);
24085          var phys=parseInt(chip.getAttribute('data-physical'),10);
24086          if(isNaN(code)||isNaN(phys)||phys===0)return;
24087          var pct=(code/phys*100).toFixed(1)+'%';
24088          var valEl=chip.querySelector('.stat-chip-val');
24089          if(valEl)valEl.textContent=pct;
24090        });
24091        // Populate author handle from data-author attribute
24092        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24093          var author=chip.getAttribute('data-author');
24094          var el=chip.querySelector('.author-handle');
24095          if(el)el.textContent='/'+author.replace(/\s+/g,'');
24096        });
24097        // Click-to-copy on run-id-chip elements
24098        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24099          chip.addEventListener('click',function(){
24100            var val=chip.getAttribute('data-copy');
24101            if(!val)return;
24102            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24103            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);}
24104            chip.classList.add('chip-copied-flash');
24105            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24106          });
24107        });
24108        // Format delta card values with data-raw using comma-separated full numbers
24109        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24110          var raw=parseInt(card.getAttribute('data-raw'),10);
24111          if(isNaN(raw))return;
24112          var valEl=card.querySelector('.delta-card-val');
24113          if(valEl)valEl.textContent=raw.toLocaleString();
24114        });
24115        // Format code-before / code-now numbers in the compare banner stats line
24116        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24117          var raw=parseInt(el.getAttribute('data-raw'),10);
24118          if(!isNaN(raw))el.textContent=raw.toLocaleString();
24119        });
24120      })();
24121
24122      // ── Shared tooltip for all result-page charts ─────────────────────────
24123      var rTT=(function(){
24124        var el=document.getElementById('r-tt');
24125        if(!el)return{s:function(){},h:function(){},m:function(){}};
24126        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24127        function hide(){el.style.display='none';}
24128        function move(e){
24129          var x=e.clientX+16,y=e.clientY-12;
24130          var r=el.getBoundingClientRect();
24131          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24132          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24133          el.style.left=x+'px';el.style.top=y+'px';
24134        }
24135        return{s:show,h:hide,m:move};
24136      })();
24137      window.rTT=rTT;
24138
24139      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24140      (function(){
24141        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24142        document.addEventListener('mouseover',function(e){
24143          var t=e.target;
24144          while(t&&t.getAttribute){
24145            var l=t.getAttribute('data-ttl');
24146            if(l!==null){
24147              var v=t.getAttribute('data-ttv')||'';
24148              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24149              return;
24150            }
24151            t=t.parentNode;
24152          }
24153        });
24154        document.addEventListener('mouseout',function(e){
24155          var t=e.target;
24156          while(t&&t.getAttribute){
24157            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24158            t=t.parentNode;
24159          }
24160        });
24161        document.addEventListener('mousemove',function(e){
24162          var el=document.getElementById('r-tt');
24163          if(el&&el.style.display!=='none')rTT.m(e);
24164        });
24165        window.addEventListener('blur',function(){rTT.h();});
24166        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24167      })();
24168
24169      // ── Language overview charts ───────────────────────────────────────────
24170      (function(){
24171        var D={{ lang_chart_json|safe }};
24172        if(!D||!D.length)return;
24173        var el=document.getElementById('result-lang-charts');
24174        if(!el)return;
24175        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24176        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24177        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24178        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();}
24179        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24180        function px(n){return Math.round(n);}
24181        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+'"';}
24182        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24183        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24184        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24185        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;}
24186        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24187
24188        // Donut chart — height matches the stacked-bar chart so both panels align
24189        var rHb_d=28;
24190        var DH=Math.max(220,D.length*rHb_d+32);
24191        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24192        var legX=208,DW=395;
24193        var legCount=D.length;
24194        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24195        var legYStart=Math.round((DH-legCount*legSpacing)/2);
24196        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">';
24197        // One shared transition on every donut element so slices, leader lines,
24198        // outside labels, % labels and the legend all animate together as a single
24199        // picture when a language is hovered. Slices scale from the donut centre.
24200        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>';
24201        if(D.length===1){
24202          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24203          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+'"/>';
24204        } else {
24205          var smalls=[];
24206          var ang=-Math.PI/2;
24207          D.forEach(function(d,i){
24208            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24209            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24210            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24211            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24212            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24213            var pct=Math.round(d.code/tot*100);
24214            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"/>';
24215            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]});}
24216            ang+=sw;
24217          });
24218          // Small slices (<5%) get outside labels positioned near each slice's own
24219          // angular position (a slice on the left gets its label/leader on the left),
24220          // then nudged apart horizontally so text never overlaps. Leader lines point
24221          // from each slice to its label. Horizontal text keeps long names legible;
24222          // the whole SVG scales up in Full View so these stay readable there too.
24223          if(smalls.length){
24224            smalls.sort(function(a,b){return a.mAng-b.mAng;});
24225            var sPad=6,sRowY=11;
24226            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)));});
24227            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;}
24228            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24229            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24230            smalls.forEach(function(sm){
24231              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24232              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;"/>';
24233              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>';
24234            });
24235          }
24236        }
24237        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24238        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24239        D.forEach(function(d,i){
24240          var ly=legYStart+i*legSpacing;
24241          var pctL=Math.round(d.code/tot*100);
24242          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24243          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24244          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24245          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24246          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24247          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24248          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>';
24249          ds+='</g>';
24250        });
24251        ds+='</svg>';
24252
24253        // Horizontal stacked-bar chart — fills container width
24254        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24255        var LW=108,BW=260,svgW=LW+BW+68;
24256        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24257        var barBH=Math.min(32,Math.round(barRhb*0.7));
24258        var SH=DH;
24259        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24260        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">';
24261        D.forEach(function(d,i){
24262          var y=barTopPad+i*barRhb,x=LW;
24263          var phys=d.physical||d.code+d.comments+d.blanks;
24264          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24265          var lmid=y+barBH/2+4;
24266          // Combined breakdown shown when hovering the row, the language name, or the
24267          // total at the bar end (\n becomes a line break in the tooltip).
24268          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24269          bs+='<g class="lang-bar-row">';
24270          // Hit area ends just past the total label so empty space to the right of the
24271          // bar does not trigger the tooltip — only the name, bar and total are hot.
24272          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24273          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24274          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>';
24275          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;}
24276          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;}
24277          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>';}
24278          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>';
24279          bs+='</g>';
24280        });
24281        var ly=SH-14;
24282        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24283        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24284        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24285        var totAll=totC+totCm+totBl||1;
24286        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24287        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24288        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24289        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24290        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24291        bs+='<g data-kind="code" style="cursor:pointer;">'
24292          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24293          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24294          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24295          +'</g>';
24296        bs+='<g data-kind="comment" style="cursor:pointer;">'
24297          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24298          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24299          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24300          +'</g>';
24301        bs+='<g data-kind="blank" style="cursor:pointer;">'
24302          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24303          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24304          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24305          +'</g>';
24306        bs+='</svg>';
24307        el.innerHTML='<div class="r-lang-overview">'+
24308          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24309          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24310        '</div>';
24311        function wireDonutLegend(svg){
24312          if(!svg)return;
24313          // Every donut element carries data-lang: slices (path/circle), leader lines,
24314          // outside labels + % labels (text) and legend rows (g). Hovering any one of
24315          // them emphasises that language across all of them and fades the rest, so the
24316          // slice, its leader line, its label and its legend row move as one picture.
24317          var items=svg.querySelectorAll('[data-lang]');
24318          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24319            var tag=el.tagName.toLowerCase();
24320            if(tag==='path'||tag==='circle'){
24321              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)';}
24322              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24323              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24324            }else if(tag==='line'){
24325              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24326              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24327              else{el.style.opacity='';el.style.strokeWidth='';}
24328            }else if(tag==='text'){
24329              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24330              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24331              else{el.style.opacity='';el.style.fontWeight='';}
24332            }else{ // legend group
24333              if(st===1){el.style.opacity='1';}
24334              else if(st===-1){el.style.opacity='0.4';}
24335              else{el.style.opacity='';}
24336            }
24337          }
24338          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24339          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24340          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();});
24341          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();});
24342          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24343        }
24344        function wireMixLegend(svg){
24345          if(!svg)return;
24346          var legGs=svg.querySelectorAll('g[data-kind]');
24347          var allRects=svg.querySelectorAll('rect[data-kind]');
24348          if(!legGs.length)return;
24349          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';}}
24350          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='';}}
24351          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]);}
24352        }
24353        wireDonutLegend(el.querySelector('svg'));
24354        wireMixLegend(el.querySelectorAll('svg')[1]);
24355
24356        // ── Language breakdown Full View expand ─────────────────────────────────
24357        var langOvBtn=document.getElementById('result-lang-overview-expand');
24358        if(langOvBtn){langOvBtn.addEventListener('click',function(){
24359          var src=document.getElementById('result-lang-charts');
24360          if(!src)return;
24361          var overlay=document.createElement('div');
24362          overlay.className='r-chart-modal-overlay';
24363          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>';
24364          document.body.appendChild(overlay);
24365          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24366          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24367          var wrap=document.getElementById('result-lang-overview-modal-wrap');
24368          if(wrap){
24369            wrap.innerHTML=src.innerHTML;
24370            var svgs=wrap.querySelectorAll('svg');
24371            for(var i=0;i<svgs.length;i++){
24372              svgs[i].removeAttribute('width');
24373              svgs[i].removeAttribute('height');
24374              svgs[i].style.cssText='display:block;width:100%;height:auto;';
24375            }
24376            var ov=wrap.querySelector('.r-lang-overview');
24377            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24378            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24379            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24380            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24381            wireDonutLegend(wrap.querySelector('svg'));
24382            wireMixLegend(wrap.querySelectorAll('svg')[1]);
24383            requestAnimationFrame(function(){
24384              var ss=wrap.querySelectorAll('svg');
24385              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%;';}}
24386            });
24387          }
24388        });}
24389      })();
24390
24391      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24392      (function(){
24393        var LANG_D={{ lang_chart_json|safe }};
24394        var SCAT_D={{ scatter_chart_json|safe }};
24395        var SEM_D={{ semantic_chart_json|safe }};
24396        var SUB_D={{ submodule_chart_json|safe }};
24397        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24398        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24399        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();}
24400        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24401        function px(n){return Math.round(n);}
24402        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+'"';}
24403        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24404        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24405        // rather than disappear; the SVG scales up in Full View).
24406        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;}
24407
24408        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24409        function renderCompositionInEl(el,mode,shOvr){
24410          if(!el||!LANG_D||!LANG_D.length)return;
24411          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24412          var LW=110,SH=shOvr||300;
24413          var svgW=Math.max(320,el.offsetWidth||480);
24414          var BW=Math.max(120,svgW-LW-80);
24415          var legendH=24,topPad=4;
24416          var n=LANG_D.length||1;
24417          var rowTotal=Math.floor((SH-legendH-topPad)/n);
24418          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24419          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">';
24420          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24421          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24422          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24423          var totAll2=totC2+totCm2+totBl2||1;
24424          if(mode==='pct'){
24425            LANG_D.forEach(function(d,i){
24426              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24427              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24428              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24429              var lmid=y+Math.floor(bH/2)+4;
24430              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24431              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>';
24432              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;}
24433              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;}
24434              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>';}
24435              var pct=Math.round((d.code||0)/tot2*100);
24436              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>';
24437            });
24438          } else {
24439            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24440            LANG_D.forEach(function(d,i){
24441              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24442              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24443              var lmid=y+Math.floor(bH/2)+4;
24444              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));
24445              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>';
24446              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;}
24447              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;}
24448              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>';}
24449              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>';
24450            });
24451          }
24452          var ly=SH-legendH+4;
24453          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24454          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24455          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24456          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24457          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24458          s+='<g data-kind="code" style="cursor:pointer;">'
24459            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24460            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24461            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24462            +'</g>';
24463          s+='<g data-kind="comment" style="cursor:pointer;">'
24464            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24465            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24466            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24467            +'</g>';
24468          s+='<g data-kind="blank" style="cursor:pointer;">'
24469            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24470            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24471            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24472            +'</g>';
24473          s+='</svg>';
24474          el.innerHTML=s;
24475          wireMixLegendEl(el);
24476        }
24477        function wireMixLegendEl(container){
24478          var svg=container&&container.querySelector('svg');
24479          if(!svg)return;
24480          var legGs=svg.querySelectorAll('g[data-kind]');
24481          var allRects=svg.querySelectorAll('rect[data-kind]');
24482          if(!legGs.length)return;
24483          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';}}
24484          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='';}}
24485          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]);}
24486        }
24487        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24488        renderComposition('abs');
24489        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24490          btn.addEventListener('click',function(){
24491            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24492            btn.classList.add('active');
24493            renderComposition(btn.getAttribute('data-rcomp'));
24494          });
24495        });
24496
24497        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24498        function wireScatterLegend(container){
24499          var svg=container&&container.querySelector('svg');
24500          if(!svg)return;
24501          var legGs=svg.querySelectorAll('g[data-lang]');
24502          var circs=svg.querySelectorAll('circle[data-lang]');
24503          var labs=svg.querySelectorAll('text[data-lang]');
24504          if(!legGs.length)return;
24505          // Raise an element to the top of its parent so the hovered bubble and its
24506          // name/number labels sit above overlapping neighbours (clustered bubbles
24507          // otherwise bury the one you are trying to read).
24508          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24509          function hl(lang){
24510            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';}}
24511            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';}}
24512            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24513          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='';}}
24514          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]);}
24515        }
24516        function renderScatterInEl(el,hOvr){
24517          if(!el||!SCAT_D||!SCAT_D.length)return;
24518          var n=SCAT_D.length;
24519          var H=hOvr||300,PL=52,PB=36,PT=44;
24520          var W=Math.max(320,el.offsetWidth||480);
24521          var cH=H-PT-PB;
24522          // Legend: max 2 columns, fills vertical space. The compact card shows the
24523          // top languages by code lines plus a "+N more" row linking to Full View;
24524          // Full View (hOvr set) shows every language across up to 2 tall columns.
24525          var compact=!hOvr;
24526          var availH=Math.max(120,H-24);
24527          var rowsFit=Math.max(2,Math.floor(availH/18));
24528          var legTrunc=compact&&(n>2*rowsFit);
24529          var legShown=legTrunc?(2*rowsFit-1):n;
24530          var legTotal=legTrunc?(2*rowsFit):n;
24531          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24532          var legPerCol=Math.ceil(legTotal/legCols);
24533          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24534          var legColW=hOvr?144:130;
24535          var LG=26;
24536          var legW=legCols*legColW;
24537          var cW=W-PL-LG-legW;
24538          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24539          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24540          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24541          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24542          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24543          var logMaxF=Math.log1p(maxF);
24544          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">';
24545          // Smooth the legend-hover fade so bubbles + labels animate together.
24546          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24547          // Y grid lines (linear)
24548          [0,0.25,0.5,0.75,1].forEach(function(t){
24549            var y=PT+cH*(1-t);
24550            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24551            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>';
24552          });
24553          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24554          [0,0.25,0.5,0.75,1].forEach(function(t){
24555            var x=PL+cW*t;
24556            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24557            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24558            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>';
24559          });
24560          // Full View (hOvr set) has the vertical room to show the per-bubble value
24561          // line; the compact card shows only the language label to avoid the
24562          // overlapping-label clutter seen when bubbles cluster together.
24563          var showVal=!!hOvr;
24564          SCAT_D.forEach(function(d,i){
24565            // X uses log1p so outlier languages (many files) don't push others to the far left
24566            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24567            var cy2=PT+cH-d.code/maxC*cH;
24568            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24569            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"/>';
24570            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24571            if(showVal){
24572              var ty2=Math.max(24,px(cy2)-px(r)-3);
24573              var ty1=Math.max(12,ty2-14);
24574              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>';
24575              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>';
24576            }else{
24577              var ly2=Math.max(12,px(cy2)-px(r)-3);
24578              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>';
24579            }
24580          });
24581          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>';
24582          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>';
24583          // Legend (right side — top languages, max 2 columns, fills height)
24584          var legX=PL+cW+LG;
24585          var legBlockH=legPerCol*legRowH;
24586          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24587          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24588          for(var lk=0;lk<legShown;lk++){
24589            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24590            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24591            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;">';
24592            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24593            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24594            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>';
24595            s+='</g>';
24596          }
24597          if(legTrunc){
24598            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24599            s+='<g data-more="1" style="cursor:pointer;">';
24600            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24601            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24602            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>';
24603            s+='</g>';
24604          }
24605          s+='</svg>';
24606          el.innerHTML=s;
24607          wireScatterLegend(el);
24608          var moreEl=el.querySelector('g[data-more]');
24609          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24610        }
24611        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24612
24613        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24614        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24615        // the old vertical column layout on wide containers.
24616        function renderSemanticInEl(el,key,sh){
24617          if(!el||!SEM_D||!SEM_D.length)return;
24618          var n2=SEM_D.length||1;
24619          var LW=112,SH=sh||Math.max(180,n2*28+26);
24620          var svgW=Math.max(320,el.offsetWidth||480);
24621          var BW=Math.max(120,svgW-LW-80);
24622          var topPad=4,botPad=14;
24623          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24624          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24625          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24626          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">';
24627          SEM_D.forEach(function(d,i){
24628            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24629            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>';
24630            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"/>';
24631            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>';
24632          });
24633          s+='</svg>';
24634          el.innerHTML=s;
24635        }
24636        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24637        var semSel=document.getElementById('r-semantic-metric');
24638        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24639        var semExpand=document.getElementById('r-semantic-expand');
24640        if(semExpand){
24641          semExpand.addEventListener('click',function(){
24642            var key=semSel?semSel.value:'functions';
24643            var n=SEM_D.length||1;
24644            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24645            var modalH=Math.min(Math.max(360,n*38+60),maxH);
24646            var overlay=document.createElement('div');
24647            overlay.className='r-chart-modal-overlay';
24648            var optHtml=
24649              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24650              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24651              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24652              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24653              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24654            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>';
24655            document.body.appendChild(overlay);
24656            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24657            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24658            var modalEl=document.getElementById('r-sem-modal-chart');
24659            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24660            var modalSel=document.getElementById('r-sem-modal-metric');
24661            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24662          });
24663        }
24664
24665        // ── Expand buttons: re-render charts at large size inside modal ──────────
24666        (function(){
24667          function makeExpandModal(title,mH,subtitle,ctrlHtml){
24668            var overlay=document.createElement('div');
24669            overlay.className='r-chart-modal-overlay';
24670            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
24671            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
24672            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>';
24673            document.body.appendChild(overlay);
24674            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24675            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24676            return overlay.querySelector('.r-expand-modal-chart');
24677          }
24678          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
24679          var compExpandBtn=document.getElementById('r-composition-expand');
24680          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
24681            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
24682            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24683            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
24684              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
24685            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
24686            if(wrap){
24687              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
24688              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
24689                btn.addEventListener('click',function(){
24690                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
24691                  btn.classList.add('active');
24692                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
24693                });
24694              });
24695            }
24696          });}
24697          var scatExpandBtn=document.getElementById('r-scatter-expand');
24698          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
24699            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
24700            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
24701          });}
24702          var densExpandBtn=document.getElementById('r-density-expand');
24703          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
24704            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
24705            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
24706            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
24707          });}
24708          var avgExpandBtn=document.getElementById('r-avglines-expand');
24709          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
24710            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
24711            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
24712            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
24713          });}
24714          var subExpandBtn=document.getElementById('r-submodule-expand');
24715          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
24716            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
24717            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
24718            var metCtrl=
24719              '<select class="r-chart-select" id="r-sub-modal-metric">'
24720              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
24721              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
24722              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
24723              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
24724              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
24725              +'</select>';
24726            var sortCtrl=
24727              '<select class="r-chart-select" id="r-sub-modal-sort">'
24728              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
24729              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
24730              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
24731              +'</select>';
24732            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
24733            if(wrap){
24734              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
24735              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
24736              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
24737              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
24738              if(mSub)mSub.addEventListener('change',reRenderSub);
24739              if(mSort)mSort.addEventListener('change',reRenderSub);
24740            }
24741          });}
24742        })();
24743
24744        // ── Comment Density: comments / (code + comments) per language ───────────
24745        function renderDensityInEl(el,shOvr){
24746          if(!el||!LANG_D||!LANG_D.length)return;
24747          var n=LANG_D.length||1;
24748          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24749          var svgW=Math.max(320,el.offsetWidth||480);
24750          var BW=Math.max(120,svgW-LW-80);
24751          var topPad=4,botPad=26;
24752          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24753          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24754          var densities=LANG_D.map(function(d){
24755            var sig=(d.code||0)+(d.comments||0);
24756            return sig>0?(d.comments||0)/sig:0;
24757          });
24758          var maxDen=Math.max.apply(null,densities)||1;
24759          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">';
24760          LANG_D.forEach(function(d,i){
24761            var den=densities[i],bw=den/maxDen*BW;
24762            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24763            var pct=Math.round(den*100);
24764            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>';
24765            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"/>';
24766            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24767            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>';
24768          });
24769          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>';
24770          s+='</svg>';
24771          el.innerHTML=s;
24772        }
24773        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
24774        renderDensity();
24775
24776        // ── Avg Lines per File: code / files per language ─────────────────────
24777        function renderAvgLinesInEl(el,shOvr){
24778          if(!el||!LANG_D||!LANG_D.length)return;
24779          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
24780          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
24781          var n=data.length||1;
24782          var LW=112,SH=shOvr||Math.max(180,n*28+26);
24783          var svgW=Math.max(320,el.offsetWidth||480);
24784          var BW=Math.max(120,svgW-LW-80);
24785          var topPad=4,botPad=26;
24786          var rowTotal=Math.floor((SH-topPad-botPad)/n);
24787          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24788          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
24789          var maxAvg=Math.max.apply(null,avgs)||1;
24790          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">';
24791          data.forEach(function(d,i){
24792            var avg=avgs[i],bw=avg/maxAvg*BW;
24793            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
24794            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>';
24795            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"/>';
24796            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24797            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>';
24798          });
24799          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>';
24800          s+='</svg>';
24801          el.innerHTML=s;
24802        }
24803        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
24804        renderAvgLines();
24805
24806        // ── Repository Overview: overall row + per-submodule rows ────────────
24807        function renderSubmoduleInEl(el,key,sort,shOvr){
24808          if(!el)return;
24809          var overall={
24810            name:'Overall',
24811            code:{{ code_lines }},
24812            comment:{{ comment_lines }},
24813            blank:{{ blank_lines }},
24814            physical:{{ physical_lines }},
24815            files:{{ files_analyzed }},
24816            isOverall:true
24817          };
24818          var subs=SUB_D.slice();
24819          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
24820          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
24821          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
24822          var data=[overall].concat(subs);
24823          var sepH=subs.length>0?14:0;
24824          var naturalH=data.length*32+sepH+16;
24825          var SH=shOvr||Math.max(100,naturalH);
24826          var svgW=Math.max(320,el.offsetWidth||480);
24827          var LW=116,BW=Math.max(200,svgW-LW-54);
24828          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
24829          var OVERALL_COL='#6b7280';
24830          var topPad=4,botPad=8;
24831          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
24832          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
24833          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">';
24834          var yOff=topPad;
24835          data.forEach(function(d,i){
24836            var v=d[key]||0,bw=v/maxV*BW;
24837            var y=yOff+Math.floor((rowSlot-bH)/2);
24838            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
24839            var label=d.name||d.path||'?';
24840            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>';
24841            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
24842            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
24843            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>';
24844            yOff+=rowSlot;
24845            if(d.isOverall&&subs.length>0){
24846              yOff+=sepH;
24847            }
24848          });
24849          s+='</svg>';
24850          el.innerHTML=s;
24851        }
24852        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
24853        var subSel=document.getElementById('r-sub-metric');
24854        var sortSel=document.getElementById('r-sub-sort');
24855        renderSubmodule('code','desc');
24856        if(subSel){
24857          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
24858          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
24859        }
24860
24861        // Equalise heights within each chart row: if one chart in a grid row is taller
24862        // than its neighbour, re-render the shorter one at the taller height so bars fill
24863        // the available vertical space instead of leaving a gap.
24864        function syncRowHeights(){
24865          var avgEl=document.getElementById('r-avglines-chart');
24866          var subEl=document.getElementById('r-submodule-chart');
24867          if(avgEl&&subEl){
24868            var avgSvg=avgEl.querySelector('svg');
24869            var subSvg=subEl.querySelector('svg');
24870            if(avgSvg&&subSvg){
24871              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
24872              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
24873              var key=subSel?subSel.value||'code':'code';
24874              var sort=sortSel?sortSel.value:'desc';
24875              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
24876              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
24877            }
24878          }
24879          var semEl=document.getElementById('r-semantic-chart');
24880          var denEl=document.getElementById('r-density-chart');
24881          if(semEl&&denEl){
24882            var semSvg=semEl.querySelector('svg');
24883            var denSvg=denEl.querySelector('svg');
24884            if(semSvg&&denSvg){
24885              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
24886              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
24887              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
24888              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
24889            }
24890          }
24891        }
24892        syncRowHeights();
24893
24894        // Re-render all SVG charts when the window is resized so bars fill the card.
24895        var _rResizeTimer;
24896        window.addEventListener('resize',function(){
24897          clearTimeout(_rResizeTimer);
24898          _rResizeTimer=setTimeout(function(){
24899            var rcompBtn=document.querySelector('[data-rcomp].active');
24900            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
24901            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24902            if(semSel)renderSemantic(semSel.value||'functions');
24903            renderDensity();
24904            renderAvgLines();
24905            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
24906            syncRowHeights();
24907          },120);
24908        });
24909      })();
24910
24911      (function randomizeWatermarks() {
24912        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
24913        if (!wms.length) return;
24914        var placed = [];
24915        function tooClose(top, left) {
24916          for (var i = 0; i < placed.length; i++) {
24917            var dt = Math.abs(placed[i][0] - top);
24918            var dl = Math.abs(placed[i][1] - left);
24919            if (dt < 20 && dl < 18) return true;
24920          }
24921          return false;
24922        }
24923        function pick(leftBand) {
24924          for (var attempt = 0; attempt < 50; attempt++) {
24925            var top = Math.random() * 85 + 5;
24926            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24927            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
24928          }
24929          var top = Math.random() * 85 + 5;
24930          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
24931          placed.push([top, left]);
24932          return [top, left];
24933        }
24934        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
24935        var half = Math.floor(wms.length / 2);
24936        wms.forEach(function (img, i) {
24937          var pos = pick(i < half);
24938          var size = Math.floor(Math.random() * 100 + 160);
24939          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
24940          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
24941          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;
24942        });
24943      })();
24944
24945      (function spawnCodeParticles() {
24946        var container = document.getElementById('code-particles');
24947        if (!container) return;
24948        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'];
24949        for (var i = 0; i < 38; i++) {
24950          (function(idx) {
24951            var el = document.createElement('span');
24952            el.className = 'code-particle';
24953            el.textContent = snippets[idx % snippets.length];
24954            var left = Math.random() * 94 + 2;
24955            var top = Math.random() * 88 + 6;
24956            var dur = (Math.random() * 10 + 9).toFixed(1);
24957            var delay = (Math.random() * 18).toFixed(1);
24958            var rot = (Math.random() * 26 - 13).toFixed(1);
24959            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
24960            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';
24961            container.appendChild(el);
24962          })(i);
24963        }
24964      })();
24965
24966      {% if pdf_generating %}
24967      // Poll for PDF readiness and swap the disabled button to a live link once done.
24968      (function() {
24969        var openBtn = document.getElementById('pdf-open-btn');
24970        var dlBtn = document.getElementById('pdf-download-btn');
24971        function checkPdf() {
24972          fetch('/api/runs/{{ run_id }}/pdf-status')
24973            .then(function(r) { return r.json(); })
24974            .then(function(d) {
24975              if (d.ready) {
24976                if (openBtn) {
24977                  var a = document.createElement('a');
24978                  a.className = 'button';
24979                  a.id = 'pdf-open-btn';
24980                  a.href = '/runs/pdf/{{ run_id }}';
24981                  a.target = '_blank';
24982                  a.rel = 'noopener';
24983                  a.textContent = 'Open PDF';
24984                  openBtn.replaceWith(a);
24985                }
24986                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
24987              } else {
24988                setTimeout(checkPdf, 3000);
24989              }
24990            })
24991            .catch(function() { setTimeout(checkPdf, 5000); });
24992        }
24993        setTimeout(checkPdf, 3000);
24994      })();
24995      {% endif %}
24996
24997    })();
24998  </script>
24999  <script nonce="{{ csp_nonce }}">
25000  (function(){
25001    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'}];
25002    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);});}
25003    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25004    function init(){
25005      var btn=document.getElementById('settings-btn');if(!btn)return;
25006      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25007      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>';
25008      document.body.appendChild(m);
25009      var g=document.getElementById('scheme-grid');
25010      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);});
25011      var cl=document.getElementById('settings-close');
25012      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);});})();
25013      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');});
25014      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25015      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25016    }
25017    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25018  }());
25019  </script>
25020  <footer class="site-footer">
25021    local code analysis - metrics, history and reports
25022    &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>
25023    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25024    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25025    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25026    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25027  </footer>
25028  {% if confluence_configured %}
25029  <script nonce="{{ csp_nonce }}">
25030  (function() {
25031    var postBtn = document.getElementById('postConfluenceBtn');
25032    var copyBtn = document.getElementById('copyWikiBtn');
25033    var modal   = document.getElementById('confluenceModal');
25034    if (!postBtn || !modal) return;
25035
25036    postBtn.addEventListener('click', function() {
25037      document.getElementById('confStatus').style.display = 'none';
25038      modal.style.display = 'flex';
25039    });
25040    document.getElementById('confCancelBtn').addEventListener('click', function() {
25041      modal.style.display = 'none';
25042    });
25043    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25044
25045    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25046      var btn = this;
25047      btn.disabled = true;
25048      var status = document.getElementById('confStatus');
25049      status.style.display = 'block';
25050      status.style.background = '#dbeafe';
25051      status.style.color = '#1e40af';
25052      status.textContent = 'Posting to Confluence\u2026';
25053      var resp = await fetch('/api/confluence/post', {
25054        method: 'POST',
25055        headers: { 'Content-Type': 'application/json' },
25056        body: JSON.stringify({
25057          run_id: '{{ run_id }}',
25058          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25059          report_url: document.getElementById('confReportUrl').value.trim() || null
25060        })
25061      });
25062      var data = await resp.json();
25063      if (data.ok) {
25064        status.style.background = '#dcfce7'; status.style.color = '#166534';
25065        status.textContent = 'Posted! Page ID: ' + data.page_id;
25066      } else {
25067        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25068        status.textContent = 'Error: ' + (data.error || 'Unknown error');
25069      }
25070      btn.disabled = false;
25071    });
25072
25073    if (copyBtn) {
25074      copyBtn.addEventListener('click', async function() {
25075        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25076        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25077        var text = await resp.text();
25078        try {
25079          await navigator.clipboard.writeText(text);
25080          var orig = copyBtn.textContent;
25081          copyBtn.textContent = 'Copied!';
25082          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25083        } catch(e) {
25084          alert('Clipboard write failed \u2014 check browser permissions.');
25085        }
25086      });
25087    }
25088  })();
25089  </script>
25090  {% endif %}
25091  <script nonce="{{ csp_nonce }}">
25092  (function() {
25093    var deleteBtn = document.getElementById('delete-run-btn');
25094    var modal     = document.getElementById('delete-run-modal');
25095    var cancelBtn = document.getElementById('delete-run-cancel');
25096    var confirmBtn= document.getElementById('delete-run-confirm');
25097    if (!deleteBtn || !modal) return;
25098    deleteBtn.addEventListener('click', function() {
25099      document.getElementById('delete-run-status').style.display = 'none';
25100      modal.style.display = 'flex';
25101    });
25102    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25103    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25104    confirmBtn.addEventListener('click', async function() {
25105      confirmBtn.disabled = true;
25106      cancelBtn.disabled = true;
25107      var status = document.getElementById('delete-run-status');
25108      status.style.display = 'block';
25109      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25110      status.textContent = 'Deleting\u2026';
25111      try {
25112        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25113        if (resp.status === 204 || resp.ok) {
25114          status.style.background = '#dcfce7'; status.style.color = '#166534';
25115          status.textContent = 'Deleted. Redirecting\u2026';
25116          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25117        } else {
25118          var d = await resp.json().catch(function(){return {};});
25119          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25120          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25121          confirmBtn.disabled = false;
25122          cancelBtn.disabled = false;
25123        }
25124      } catch (e) {
25125        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25126        status.textContent = 'Network error: ' + String(e);
25127        confirmBtn.disabled = false;
25128        cancelBtn.disabled = false;
25129      }
25130    });
25131  })();
25132  </script>
25133  <script nonce="{{ csp_nonce }}">(function(){
25134    var bundleBtn = document.getElementById('download-bundle-btn');
25135    if (bundleBtn) {
25136      bundleBtn.addEventListener('click', function() {
25137        bundleBtn.disabled = true;
25138        var orig = bundleBtn.textContent;
25139        bundleBtn.textContent = 'Preparing\u2026';
25140        fetch('/api/runs/{{ run_id }}/bundle')
25141          .then(function(r) {
25142            if (!r.ok) throw new Error('HTTP ' + r.status);
25143            return r.blob();
25144          })
25145          .then(function(blob) {
25146            var url = URL.createObjectURL(blob);
25147            var a = document.createElement('a');
25148            a.href = url;
25149            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25150            document.body.appendChild(a);
25151            a.click();
25152            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25153            bundleBtn.disabled = false;
25154            bundleBtn.textContent = orig;
25155          })
25156          .catch(function(e) {
25157            bundleBtn.disabled = false;
25158            bundleBtn.textContent = orig;
25159            alert('Bundle download failed: ' + String(e));
25160          });
25161      });
25162    }
25163  })();</script>
25164  <script nonce="{{ csp_nonce }}">(function(){
25165    var dot=document.getElementById('status-dot');
25166    var pingEl=document.getElementById('server-ping-ms');
25167    var tipEl=document.getElementById('server-tip-ping');
25168    var fm=document.getElementById('footer-mode');
25169    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)';}}
25170    function doPing(){
25171      var t0=performance.now();
25172      fetch('/healthz',{cache:'no-store'})
25173        .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);})
25174        .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)';}});
25175    }
25176    doPing();
25177    setInterval(doPing,5000);
25178    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');}
25179  })();</script>
25180  <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>
25181  {% if let Some(banner) = report_header_footer %}
25182  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25183  {% endif %}
25184</body>
25185</html>
25186"##,
25187    ext = "html"
25188)]
25189// Template structs need many bool fields to pass Askama rendering flags.
25190#[allow(clippy::struct_excessive_bools)]
25191struct ResultTemplate {
25192    version: &'static str,
25193    report_title: String,
25194    project_path: String,
25195    output_dir: String,
25196    run_id: String,
25197    files_analyzed: u64,
25198    files_skipped: u64,
25199    physical_lines: u64,
25200    code_lines: u64,
25201    comment_lines: u64,
25202    blank_lines: u64,
25203    mixed_lines: u64,
25204    functions: u64,
25205    classes: u64,
25206    variables: u64,
25207    imports: u64,
25208    html_url: Option<String>,
25209    pdf_url: Option<String>,
25210    json_url: Option<String>,
25211    html_download_url: Option<String>,
25212    pdf_download_url: Option<String>,
25213    json_download_url: Option<String>,
25214    html_path: Option<String>,
25215    json_path: Option<String>,
25216    prev_run_id: Option<String>,
25217    prev_run_timestamp: Option<String>,
25218    prev_run_code_lines: Option<u64>,
25219    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
25220    prev_fa_str: String,
25221    prev_fs_str: String,
25222    prev_pl_str: String,
25223    prev_cl_str: String,
25224    prev_cml_str: String,
25225    prev_bl_str: String,
25226    // Signed change column for main metrics
25227    delta_fa_str: String,
25228    delta_fa_class: String,
25229    delta_fs_str: String,
25230    delta_fs_class: String,
25231    delta_pl_str: String,
25232    delta_pl_class: String,
25233    delta_cl_str: String,
25234    delta_cl_class: String,
25235    delta_cml_str: String,
25236    delta_cml_class: String,
25237    delta_bl_str: String,
25238    delta_bl_class: String,
25239    // delta vs previous scan
25240    delta_lines_added: Option<i64>,
25241    delta_lines_removed: Option<i64>,
25242    delta_lines_net_str: String,
25243    delta_lines_net_class: String,
25244    delta_files_added: Option<usize>,
25245    delta_files_removed: Option<usize>,
25246    delta_files_modified: Option<usize>,
25247    delta_files_unchanged: Option<usize>,
25248    delta_unmodified_lines: Option<u64>,
25249    // git context
25250    git_branch: Option<String>,
25251    git_branch_url: Option<String>,
25252    git_commit: Option<String>,
25253    git_commit_long: Option<String>,
25254    git_author: Option<String>,
25255    git_commit_url: Option<String>,
25256    // scan metadata for hero section
25257    scan_performed_by: String,
25258    scan_time_display: String,
25259    scan_time_utc_ms: i64,
25260    os_display: String,
25261    test_count: u64,
25262    // reserve "pad" card, revealed by JS only when the visible card count is odd
25263    test_assertion_count: u64,
25264    // history
25265    prev_scan_count: usize,
25266    current_scan_number: usize,
25267    // submodule breakdown (empty when not requested)
25268    submodule_rows: Vec<SubmoduleRow>,
25269    scan_config_url: String,
25270    lang_chart_json: String,
25271    // Askama reads these via proc-macro expansion; clippy can't trace through it.
25272    #[allow(dead_code)]
25273    scatter_chart_json: String,
25274    #[allow(dead_code)]
25275    semantic_chart_json: String,
25276    #[allow(dead_code)]
25277    submodule_chart_json: String,
25278    #[allow(dead_code)]
25279    has_submodule_data: bool,
25280    #[allow(dead_code)]
25281    has_semantic_data: bool,
25282    pdf_generating: bool,
25283    csp_nonce: String,
25284    /// Whether Confluence integration is configured — shows Post button when true.
25285    confluence_configured: bool,
25286    server_mode: bool,
25287    /// Header/footer identification banner, mirrored from the HTML/PDF report.
25288    report_header_footer: Option<String>,
25289    run_id_short: String,
25290    /// True when rendering a static offline file (index.html); hides server-only actions.
25291    #[allow(dead_code)]
25292    is_offline: bool,
25293    /// Total cyclomatic complexity score across all analyzed files.
25294    cyclomatic_complexity: u64,
25295    /// Logical SLOC (statement count) when available; None for unsupported languages.
25296    lsloc: Option<u64>,
25297    /// Unique Lines of Code across all analyzed files.
25298    uloc: u64,
25299    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
25300    dryness_pct_str: String,
25301    /// Number of duplicate file groups detected.
25302    duplicate_group_count: usize,
25303    /// Whether a COCOMO estimate is available to display.
25304    has_cocomo: bool,
25305    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
25306    cocomo_effort_str: String,
25307    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
25308    cocomo_duration_str: String,
25309    /// Pre-formatted average team size, e.g. "2.32".
25310    cocomo_staff_str: String,
25311    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
25312    cocomo_ksloc_str: String,
25313    /// COCOMO mode label shown in the card (e.g. "Organic").
25314    cocomo_mode_label: String,
25315    /// Tooltip text explaining the selected COCOMO mode.
25316    cocomo_mode_tooltip: String,
25317    /// Per-file complexity alert threshold. 0 = off (no highlighting).
25318    complexity_alert: u32,
25319    /// Whether any file has coverage data attached.
25320    has_coverage_data: bool,
25321    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
25322    cov_line_pct: String,
25323    /// Overall function coverage percentage string — empty if no data.
25324    cov_fn_pct: String,
25325    /// Overall branch coverage percentage string — empty if no branch data.
25326    cov_branch_pct: String,
25327    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
25328    cov_lines_summary: String,
25329}
25330
25331#[derive(Template)]
25332#[template(
25333    source = r##"
25334<!doctype html>
25335<html lang="en">
25336<head>
25337  <meta charset="utf-8">
25338  <meta name="viewport" content="width=device-width, initial-scale=1">
25339  <title>OxideSLOC | Analyzing…</title>
25340  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25341  <style nonce="{{ csp_nonce }}">
25342    :root {
25343      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25344      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25345      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25346      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25347    }
25348    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25349    *{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;}
25350    .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);}
25351    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25352    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25353    .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));}
25354    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25355    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25356    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25357    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25358    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25359    @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; } }
25360    .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;}
25361    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25362    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25363    .page-body{padding:32px 24px 36px;}
25364    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25365    .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;}
25366    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25367    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25368    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25369    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25370    .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;}
25371    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25372    .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;}
25373    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25374    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25375    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25376    .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;}
25377    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25378    .hidden{display:none!important;}
25379    .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;}
25380    .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;}
25381    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25382    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25383    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25384    .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);}
25385    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25386    .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;}
25387    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25388    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25389    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25390    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25391    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25392    .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;}
25393    @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));}}
25394    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25395    .site-footer a{color:var(--muted);}
25396    .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;}
25397    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25398    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25399    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25400  </style>
25401</head>
25402<body>
25403  <div class="background-watermarks" aria-hidden="true">
25404    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25405    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25406    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25407    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25408    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25409    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25410  </div>
25411  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25412  <nav class="top-nav">
25413    <div class="top-nav-inner">
25414      <a href="/" class="brand">
25415        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25416        <div class="brand-copy">
25417          <h1 class="brand-title">OxideSLOC</h1>
25418          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25419        </div>
25420      </a>
25421      <div class="nav-right">
25422        <a class="nav-pill" href="/">Home</a>
25423        <div class="nav-dropdown">
25424          <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>
25425          <div class="nav-dropdown-menu">
25426            <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>
25427          </div>
25428        </div>
25429        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25430        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25431        <div class="nav-dropdown">
25432          <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>
25433          <div class="nav-dropdown-menu">
25434            <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>
25435          </div>
25436        </div>
25437        <div class="server-status-wrap" id="server-status-wrap">
25438          <div class="nav-pill server-online-pill" id="server-status-pill">
25439            <span class="status-dot" id="status-dot"></span>
25440            <span id="server-status-label">Server</span>
25441            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25442          </div>
25443          <div class="server-status-tip">
25444            OxideSLOC is running — accessible on your network.
25445            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25446          </div>
25447        </div>
25448        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25449          <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>
25450        </button>
25451        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25452          <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>
25453          <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>
25454        </button>
25455      </div>
25456    </div>
25457  </nav>
25458  <div class="page-body">
25459    <div class="wait-panel">
25460      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25461      <h2 class="wait-title">Analyzing your project…</h2>
25462      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25463      <div class="path-block">{{ project_path }}</div>
25464      <div class="metrics-row">
25465        <div class="metric-card">
25466          <div class="metric-label">Elapsed</div>
25467          <div class="metric-value" id="elapsed">0s</div>
25468        </div>
25469        <div class="metric-card">
25470          <div class="metric-label">Phase</div>
25471          <div class="metric-value" id="phase">Starting</div>
25472        </div>
25473        <div class="metric-card hidden" id="files-card">
25474          <div class="metric-label">Files</div>
25475          <div class="metric-value" id="files-progress">0</div>
25476        </div>
25477      </div>
25478      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25479      <div class="warn-slow hidden" id="warn-slow">
25480        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.
25481      </div>
25482      <div class="err-panel hidden" id="err-panel">
25483        <strong>Analysis failed</strong>
25484        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25485      </div>
25486      <div class="actions hidden" id="actions">
25487        <a href="/scan" class="btn-primary">Try Again</a>
25488        <a href="/view-reports" class="btn-outline">View Reports</a>
25489      </div>
25490    </div>
25491  </div>
25492  <script nonce="{{ csp_nonce }}">
25493    (function() {
25494      var WAIT_ID = {{ wait_id_json|safe }};
25495      var startTime = Date.now();
25496      var pollInterval = 1500;
25497      var retries = 0;
25498      var maxRetries = 5;
25499      var warnShown = false;
25500
25501      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();}
25502
25503      function elapsed() {
25504        return Math.floor((Date.now() - startTime) / 1000);
25505      }
25506
25507      function updateElapsed() {
25508        var s = elapsed();
25509        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25510      }
25511
25512      function setPhase(txt) {
25513        document.getElementById('phase').textContent = txt;
25514      }
25515
25516      var elapsedTimer = setInterval(updateElapsed, 1000);
25517
25518      function poll() {
25519        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25520          .then(function(r) {
25521            if (!r.ok) throw new Error('HTTP ' + r.status);
25522            return r.json();
25523          })
25524          .then(function(data) {
25525            retries = 0;
25526            if (data.state === 'complete') {
25527              clearInterval(elapsedTimer);
25528              setPhase('Done');
25529              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25530            } else if (data.state === 'failed') {
25531              clearInterval(elapsedTimer);
25532              setPhase('Failed');
25533              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25534              document.getElementById('err-panel').classList.remove('hidden');
25535              document.getElementById('actions').classList.remove('hidden');
25536            } else {
25537              // still running
25538              var s = elapsed();
25539              if (s > 90 && !warnShown) {
25540                warnShown = true;
25541                document.getElementById('warn-slow').classList.remove('hidden');
25542              }
25543              setPhase(data.phase || 'Running');
25544              var fd = data.files_done || 0, ft = data.files_total || 0;
25545              if (ft > 0) {
25546                var card = document.getElementById('files-card');
25547                if (card) card.classList.remove('hidden');
25548                var fp = document.getElementById('files-progress');
25549                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25550              }
25551              setTimeout(poll, pollInterval);
25552            }
25553          })
25554          .catch(function(err) {
25555            retries++;
25556            if (retries >= maxRetries) {
25557              clearInterval(elapsedTimer);
25558              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25559              document.getElementById('err-panel').classList.remove('hidden');
25560              document.getElementById('actions').classList.remove('hidden');
25561            } else {
25562              // exponential back-off capped at 8s
25563              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25564            }
25565          });
25566      }
25567
25568      setTimeout(poll, pollInterval);
25569
25570      // If the browser restores this page from bfcache (Back after viewing results),
25571      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25572      window.addEventListener("pageshow", function(e) {
25573        if (e.persisted) { setTimeout(poll, 200); }
25574      });
25575    })();
25576  </script>
25577  <footer class="site-footer">
25578    local code analysis - metrics, history and reports
25579    &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>
25580    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25581    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25582    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25583    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25584  </footer>
25585  <script nonce="{{ csp_nonce }}">
25586    (function(){
25587      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25588      if(s==="dark")b.classList.add("dark-theme");
25589      var tt=document.getElementById("theme-toggle");
25590      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25591    })();
25592    (function spawnCodeParticles(){
25593      var c=document.getElementById('code-particles');if(!c)return;
25594      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'];
25595      for(var i=0;i<32;i++){(function(idx){
25596        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25597        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25598        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25599        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25600        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25601        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25602        c.appendChild(el);
25603      })(i);}
25604    })();
25605    (function randomizeWatermarks(){
25606      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25607      var placed=[];
25608      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;}
25609      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];}
25610      var half=Math.floor(wms.length/2);
25611      wms.forEach(function(img,i){
25612        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25613        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25614        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25615        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25616        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25617        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25618      });
25619    })();
25620  </script>
25621  <script nonce="{{ csp_nonce }}">
25622  (function(){
25623    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'}];
25624    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);});}
25625    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25626    function init(){
25627      var btn=document.getElementById('settings-btn');if(!btn)return;
25628      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25629      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>';
25630      document.body.appendChild(m);
25631      var g=document.getElementById('scheme-grid');
25632      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);});
25633      var cl=document.getElementById('settings-close');
25634      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);});})();
25635      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');});
25636      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25637      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25638    }
25639    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25640  }());
25641  </script>
25642  <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]';
25643  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;}
25644  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>
25645</body>
25646</html>
25647"##,
25648    ext = "html"
25649)]
25650struct ScanWaitTemplate {
25651    version: &'static str,
25652    wait_id_json: String,
25653    project_path: String,
25654    csp_nonce: String,
25655}
25656
25657#[derive(Template)]
25658#[template(
25659    source = r##"
25660<!doctype html>
25661<html lang="en">
25662<head>
25663  <meta charset="utf-8">
25664  <meta name="viewport" content="width=device-width, initial-scale=1">
25665  <title>OxideSLOC | Error</title>
25666  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25667  <style nonce="{{ csp_nonce }}">
25668    :root {
25669      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25670      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25671      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25672      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25673    }
25674    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25675    *{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;}
25676    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25677    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25678    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
25679    .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);}
25680    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25681    .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));}
25682    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25683    .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;}
25684    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25685    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25686    @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; } }
25687    .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;}
25688    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25689    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25690    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25691    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25692    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25693    .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;}
25694    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25695    .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);}
25696    .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;}
25697    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25698    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25699    .settings-modal-body{padding:14px 16px 16px;}
25700    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25701    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25702    .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;}
25703    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25704    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25705    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25706    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25707    .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;}
25708    .tz-select:focus{border-color:var(--oxide);}
25709    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
25710    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25711    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
25712    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
25713    .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;}
25714    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
25715    .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);}
25716    .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;}
25717    .btn-secondary:hover{background:var(--line);}
25718    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
25719    .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;}
25720    .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;}
25721    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
25722    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
25723    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
25724    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
25725    .bug-report-panel.open{display:flex;}
25726    .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;}
25727    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
25728    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
25729    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
25730    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
25731    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
25732    .br-network-badge.online .br-net-dot{background:#2a6846;}
25733    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
25734    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
25735    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
25736    .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;}
25737    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
25738    .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;}
25739    .btn-sm:hover{background:var(--line);}
25740    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
25741    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
25742    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
25743    .bug-report-hint a:hover{text-decoration:underline;}
25744    .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;}
25745    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
25746    .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;}
25747    .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;}
25748    .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;}
25749    @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));}}
25750    .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;}
25751  </style>
25752</head>
25753<body>
25754  <div class="background-watermarks" aria-hidden="true">
25755    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25756    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25757    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25758    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25759    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25760    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25761  </div>
25762  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25763  <div class="top-nav">
25764    <div class="top-nav-inner">
25765      <a class="brand" href="/">
25766        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
25767        <div class="brand-copy">
25768          <div class="brand-title">OxideSLOC</div>
25769          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25770        </div>
25771      </a>
25772      <div class="nav-right">
25773        <a class="nav-pill" href="/">Home</a>
25774        <div class="nav-dropdown">
25775          <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>
25776          <div class="nav-dropdown-menu">
25777            <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>
25778          </div>
25779        </div>
25780        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25781        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25782        <div class="nav-dropdown">
25783          <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>
25784          <div class="nav-dropdown-menu">
25785            <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>
25786          </div>
25787        </div>
25788        <div class="server-status-wrap" id="server-status-wrap">
25789          <div class="nav-pill server-online-pill" id="server-status-pill">
25790            <span class="status-dot" id="status-dot"></span>
25791            <span id="server-status-label">Server</span>
25792            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25793          </div>
25794          <div class="server-status-tip">
25795            OxideSLOC is running — accessible on your network.
25796            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25797          </div>
25798        </div>
25799        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25800          <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>
25801        </button>
25802        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25803          <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>
25804          <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>
25805        </button>
25806      </div>
25807    </div>
25808  </div>
25809
25810  <div class="page">
25811    <div class="panel">
25812      <h1>Error</h1>
25813      <div class="error-box" id="error-msg-text">{{ message }}</div>
25814      <div id="br-meta" hidden
25815        data-version="{{ version }}"
25816        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
25817        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
25818      <div class="actions">
25819        <a class="btn-primary" href="/scan">Back to setup</a>
25820        {% if let Some(report_url) = last_report_url %}
25821        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
25822        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
25823        {% else %}
25824        <a class="btn-secondary" href="/view-reports">View Reports</a>
25825        {% endif %}
25826      </div>
25827      <div class="bug-report-section" id="bug-report-section">
25828        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
25829          <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>
25830          Generate Bug Report
25831          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
25832        </button>
25833        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
25834          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
25835          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
25836          <div class="bug-report-btns">
25837            <button type="button" class="btn-sm" id="bug-report-copy">
25838              <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>
25839              Copy to clipboard
25840            </button>
25841            <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;">
25842              <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>
25843              Open GitHub Issue
25844            </a>
25845            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
25846              <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>
25847              Save as file
25848            </button>
25849          </div>
25850          <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>
25851          <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>
25852        </div>
25853      </div>
25854    </div>
25855  </div>
25856  <footer class="site-footer">
25857    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
25858    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25859    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25860    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25861    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25862  </footer>
25863  <script nonce="{{ csp_nonce }}">(function(){
25864    var meta=document.getElementById('br-meta');
25865    var pre=document.getElementById('bug-report-pre');
25866    var copyBtn=document.getElementById('bug-report-copy');
25867    var trigger=document.getElementById('bug-report-trigger');
25868    var panel=document.getElementById('bug-report-panel');
25869    var networkBadge=document.getElementById('br-network-badge');
25870    var networkLabel=document.getElementById('br-network-label');
25871    var ghLink=document.getElementById('bug-report-github-link');
25872    var saveBtn=document.getElementById('bug-report-save');
25873    var hintOnline=document.getElementById('br-hint-online');
25874    var hintOffline=document.getElementById('br-hint-offline');
25875    if(!meta||!pre)return;
25876    var ver=meta.getAttribute('data-version')||'';
25877    var runId=meta.getAttribute('data-run-id')||'';
25878    var code=meta.getAttribute('data-error-code')||'';
25879    var msgEl=document.getElementById('error-msg-text');
25880    var msg=msgEl?msgEl.textContent.trim():'';
25881    function getBrowser(){
25882      var ua=navigator.userAgent;
25883      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
25884      if(!m)return 'Unknown browser';
25885      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
25886      return n+' '+m[2];
25887    }
25888    var lines=['oxide-sloc Bug Report','==============================',''];
25889    lines.push('App version:  v'+ver);
25890    if(code)lines.push('HTTP status:  '+code);
25891    if(runId)lines.push('Run ID:       '+runId);
25892    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
25893    lines.push('Timestamp:    '+new Date().toISOString());
25894    lines.push('Browser:      '+getBrowser());
25895    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
25896    lines.push('');
25897    lines.push('Error message:');
25898    lines.push(msg);
25899    lines.push('');
25900    lines.push('Steps to reproduce:');
25901    lines.push('  1. ');
25902    lines.push('');
25903    lines.push('Expected behavior:');
25904    lines.push('  ');
25905    pre.textContent=lines.join('\n');
25906    function applyNetwork(online){
25907      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
25908      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
25909      if(ghLink){
25910        if(online){
25911          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
25912          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
25913        }
25914        ghLink.style.display=online?'inline-flex':'none';
25915      }
25916      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
25917      if(hintOnline)hintOnline.style.display=online?'block':'none';
25918      if(hintOffline)hintOffline.style.display=online?'none':'block';
25919    }
25920    applyNetwork(navigator.onLine);
25921    var probed=false;
25922    function probeNetwork(){
25923      if(probed)return;probed=true;
25924      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
25925      var probeIdx=0;
25926      function tryNext(){
25927        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
25928        var u=probeUrls[probeIdx++];
25929        var c2=new AbortController();
25930        var t2=setTimeout(function(){c2.abort();},4000);
25931        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
25932          .then(function(){clearTimeout(t2);applyNetwork(true);})
25933          .catch(function(){clearTimeout(t2);tryNext();});
25934      }
25935      tryNext();
25936    }
25937    if(trigger&&panel){
25938      trigger.addEventListener('click',function(){
25939        var open=panel.classList.toggle('open');
25940        trigger.classList.toggle('open',open);
25941        trigger.setAttribute('aria-expanded',open?'true':'false');
25942        if(open)probeNetwork();
25943      });
25944    }
25945    if(copyBtn){
25946      copyBtn.addEventListener('click',function(){
25947        var txt=pre.textContent;
25948        if(navigator.clipboard&&navigator.clipboard.writeText){
25949          navigator.clipboard.writeText(txt).then(function(){
25950            copyBtn.textContent='\u2713 Copied!';
25951            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);
25952          });
25953        }else{
25954          var ta=document.createElement('textarea');
25955          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
25956          document.body.appendChild(ta);ta.select();
25957          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
25958          document.body.removeChild(ta);
25959        }
25960      });
25961    }
25962    if(saveBtn){
25963      saveBtn.addEventListener('click',function(){
25964        var txt=pre.textContent;
25965        var blob=new Blob([txt],{type:'text/plain'});
25966        var url=URL.createObjectURL(blob);
25967        var a=document.createElement('a');
25968        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
25969        document.body.appendChild(a);a.click();
25970        document.body.removeChild(a);URL.revokeObjectURL(url);
25971      });
25972    }
25973  })();</script>
25974  <script nonce="{{ csp_nonce }}">
25975    (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");});})();
25976    (function spawnCodeParticles() {
25977      var container = document.getElementById('code-particles');
25978      if (!container) return;
25979      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'];
25980      for (var i = 0; i < 38; i++) {
25981        (function(idx) {
25982          var el = document.createElement('span');
25983          el.className = 'code-particle';
25984          el.textContent = snippets[idx % snippets.length];
25985          var left = Math.random() * 94 + 2;
25986          var top = Math.random() * 88 + 6;
25987          var dur = (Math.random() * 10 + 9).toFixed(1);
25988          var delay = (Math.random() * 18).toFixed(1);
25989          var rot = (Math.random() * 26 - 13).toFixed(1);
25990          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25991          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';
25992          container.appendChild(el);
25993        })(i);
25994      }
25995    })();
25996    (function randomizeWatermarks() {
25997      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25998      var placed = [];
25999      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; }
26000      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]; }
26001      var half = Math.floor(wms.length/2);
26002      wms.forEach(function(img, i) {
26003        var pos = pick(i < half);
26004        var w = Math.floor(Math.random()*60+80);
26005        var rot = (Math.random()*40-20).toFixed(1);
26006        var op = (Math.random()*0.08+0.05).toFixed(2);
26007        var animDur = (Math.random()*6+5).toFixed(1);
26008        var animDelay = (Math.random()*10).toFixed(1);
26009        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';
26010      });
26011    })();
26012  </script>
26013  <script nonce="{{ csp_nonce }}">
26014  (function(){
26015    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'}];
26016    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);});}
26017    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26018    function init(){
26019      var btn=document.getElementById('settings-btn');if(!btn)return;
26020      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26021      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>';
26022      document.body.appendChild(m);
26023      var g=document.getElementById('scheme-grid');
26024      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);});
26025      var cl=document.getElementById('settings-close');
26026      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);});})();
26027      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');});
26028      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26029      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26030    }
26031    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26032  }());
26033  </script>
26034  <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]';
26035  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;}
26036  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>
26037</body>
26038</html>
26039"##,
26040    ext = "html"
26041)]
26042struct ErrorTemplate {
26043    message: String,
26044    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
26045    last_report_url: Option<String>,
26046    /// Label for the secondary action button; defaults to "View last report" when None.
26047    last_report_label: Option<String>,
26048    /// Run ID to surface in the bug report; `None` when not applicable.
26049    run_id: Option<String>,
26050    /// HTTP status code to surface in the bug report; `None` when unknown.
26051    error_code: Option<u16>,
26052    csp_nonce: String,
26053    version: &'static str,
26054}
26055
26056// ── LocateFileTemplate ────────────────────────────────────────────────────────
26057
26058#[derive(Template)]
26059#[template(
26060    source = r##"
26061<!doctype html>
26062<html lang="en">
26063<head>
26064  <meta charset="utf-8">
26065  <meta name="viewport" content="width=device-width, initial-scale=1">
26066  <title>OxideSLOC | Locate Report</title>
26067  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26068  <style nonce="{{ csp_nonce }}">
26069    :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);}
26070    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26071    *{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;}
26072    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26073    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26074    .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);}
26075    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26076    .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));}
26077    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26078    .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;}
26079    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26080    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26081    @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;}}
26082    .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;}
26083    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26084    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26085    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26086    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26087    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26088    .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;}
26089    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26090    .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);}
26091    .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;}
26092    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26093    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26094    .settings-modal-body{padding:14px 16px 16px;}
26095    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26096    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26097    .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;}
26098    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26099    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26100    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26101    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26102    .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;}
26103    .tz-select:focus{border-color:var(--oxide);}
26104    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26105    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26106    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26107    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26108    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26109    .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;}
26110    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26111    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26112    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26113    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26114    .locate-row{display:flex;gap:8px;align-items:stretch;}
26115    .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;}
26116    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26117    body.dark-theme .locate-input{background:var(--surface-2);}
26118    .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;}
26119    .warning-banner.show{display:flex;}
26120    .warning-banner svg{flex:0 0 auto;}
26121    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26122    .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;}
26123    .error-inline.show{display:flex;}
26124    .error-inline svg{flex:0 0 auto;margin-top:2px;}
26125    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26126    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26127    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26128    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26129    .err-kv-p{margin:0 0 4px;}
26130    .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;}
26131    .success-inline.show{display:flex;}
26132    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26133    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26134    .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;}
26135    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26136    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26137    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26138    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26139    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26140    .fh-row:last-child{border-bottom:none;}
26141    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26142    .fh-dir{font-weight:800;color:var(--text);}
26143    .fh-hl{color:var(--oxide);font-weight:700;}
26144    .fh-muted{color:var(--muted);}
26145    .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;}
26146    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26147    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26148    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26149    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26150    .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;}
26151    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26152    .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;}
26153    .btn-secondary:hover{background:var(--line);}
26154    .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;}
26155    .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;}
26156    .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;}
26157    @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));}}
26158    .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;}
26159    .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;}
26160    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26161  </style>
26162</head>
26163<body>
26164  <div class="background-watermarks" aria-hidden="true">
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    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26168    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26169    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26170    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26171  </div>
26172  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26173  <div class="top-nav">
26174    <div class="top-nav-inner">
26175      <a class="brand" href="/">
26176        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26177        <div class="brand-copy">
26178          <div class="brand-title">OxideSLOC</div>
26179          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26180        </div>
26181      </a>
26182      <div class="nav-right">
26183        <a class="nav-pill" href="/">Home</a>
26184        <div class="nav-dropdown">
26185          <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>
26186          <div class="nav-dropdown-menu">
26187            <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>
26188          </div>
26189        </div>
26190        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26191        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26192        <div class="nav-dropdown">
26193          <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>
26194          <div class="nav-dropdown-menu">
26195            <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>
26196          </div>
26197        </div>
26198        <div class="server-status-wrap" id="server-status-wrap">
26199          <div class="nav-pill server-online-pill" id="server-status-pill">
26200            <span class="status-dot" id="status-dot"></span>
26201            <span id="server-status-label">Server</span>
26202            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26203          </div>
26204          <div class="server-status-tip">
26205            OxideSLOC is running &mdash; accessible on your network.
26206            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26207          </div>
26208        </div>
26209        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26210          <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>
26211        </button>
26212        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26213          <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>
26214          <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>
26215        </button>
26216      </div>
26217    </div>
26218  </div>
26219
26220  <div class="page">
26221    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26222    <div class="panel">
26223      <h1>Report File Not Found</h1>
26224      <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>
26225      <div class="field-label">Missing file</div>
26226      <div class="filename-chip">
26227        <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>
26228        {{ expected_filename }}
26229      </div>
26230      <div class="locate-section">
26231        <h2>Locate Scan Output Folder</h2>
26232        <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>
26233        <p>OxideSLOC will find the correct files inside automatically.</p>
26234        <div class="locate-row">
26235          <input type="text" id="locate-file-input"
26236                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26237                 class="locate-input" autocomplete="off" spellcheck="false">
26238          {% if !server_mode %}
26239          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
26240          {% endif %}
26241        </div>
26242        <div class="warning-banner" id="filename-warning">
26243          <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>
26244          <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>
26245        </div>
26246        <div class="error-inline" id="locate-error">
26247          <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>
26248          <span id="locate-error-text"></span>
26249        </div>
26250        <div class="success-inline" id="locate-success">
26251          <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>
26252          <span>Scan restored &mdash; loading report&hellip;</span>
26253        </div>
26254        <div class="btn-row">
26255          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26256          <a class="btn-secondary" href="/view-reports">View Reports</a>
26257        </div>
26258        <div class="folder-hint-shell">
26259          <div class="folder-hint-hdr">
26260            <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>
26261            Expected Folder Structure &mdash; Select the Top-Level Folder
26262          </div>
26263          <div class="folder-hint-body">
26264            <div class="fh-row">
26265              <span class="fh-tog">&#9658;</span>
26266              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
26267              <span class="fh-badge">&larr; select this</span>
26268            </div>
26269            <div class="fh-row fh-i1">
26270              <span class="fh-tog">&#9658;</span>
26271              <span class="fh-dir">html/</span>
26272            </div>
26273            <div class="fh-row fh-i2">
26274              <span class="fh-bul">&#8226;</span>
26275              <span class="fh-hl">{{ expected_filename }}</span>
26276            </div>
26277            <div class="fh-row fh-i1">
26278              <span class="fh-tog">&#9658;</span>
26279              <span class="fh-dir">json/</span>
26280            </div>
26281            <div class="fh-row fh-i2">
26282              <span class="fh-bul">&#8226;</span>
26283              <span class="fh-muted">result_*.json</span>
26284            </div>
26285            <div class="fh-row fh-i1">
26286              <span class="fh-tog">&#9658;</span>
26287              <span class="fh-dir">pdf/</span>
26288            </div>
26289            <div class="fh-row fh-i2">
26290              <span class="fh-bul">&#8226;</span>
26291              <span class="fh-muted">report_*.pdf</span>
26292            </div>
26293            <div class="fh-row fh-i1">
26294              <span class="fh-tog">&#9658;</span>
26295              <span class="fh-dir">excel/</span>
26296            </div>
26297            <div class="fh-row fh-i2">
26298              <span class="fh-bul">&#8226;</span>
26299              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
26300            </div>
26301          </div>
26302        </div>
26303      </div>
26304    </div>
26305  </div>
26306  <footer class="site-footer">
26307    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26308    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26309    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26310    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26311    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26312  </footer>
26313  <script nonce="{{ csp_nonce }}">(function(){
26314    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26315    if(s==="dark")b.classList.add("dark-theme");
26316    document.getElementById("theme-toggle").addEventListener("click",function(){
26317      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26318    });
26319  })();</script>
26320  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26321    var c=document.getElementById('code-particles');if(!c)return;
26322    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'];
26323    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);}
26324  })();
26325  (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>
26326  <script nonce="{{ csp_nonce }}">(function(){
26327    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'}];
26328    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);});}
26329    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26330    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');});}
26331    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26332  }());</script>
26333  <script nonce="{{ csp_nonce }}">(function(){
26334    var meta=document.getElementById('locate-meta');
26335    var inp=document.getElementById('locate-file-input');
26336    var browseBtn=document.getElementById('browse-locate-btn');
26337    var submitBtn=document.getElementById('locate-submit-btn');
26338    var warning=document.getElementById('filename-warning');
26339    var errBox=document.getElementById('locate-error');
26340    var errText=document.getElementById('locate-error-text');
26341    var okBox=document.getElementById('locate-success');
26342    var expected=meta?meta.getAttribute('data-expected'):'';
26343    var runId=meta?meta.getAttribute('data-run-id'):'';
26344    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26345    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26346    function showErr(msg){
26347      if(errText){
26348        errText.innerHTML='';
26349        var lines=msg.split('\n');
26350        var hasPairs=lines.some(function(l){return / : /.test(l);});
26351        if(!hasPairs){errText.textContent=msg;}
26352        else{
26353          var frag=document.createDocumentFragment();var tbl=null;
26354          lines.forEach(function(line){
26355            var m=line.match(/^(.*?) : (.*)$/);
26356            if(m){
26357              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26358              var tr=document.createElement('tr');
26359              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26360              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26361              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26362            } else {
26363              tbl=null;
26364              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26365            }
26366          });
26367          errText.appendChild(frag);
26368        }
26369      }
26370      if(errBox)errBox.classList.add('show');
26371      if(okBox)okBox.classList.remove('show');
26372    }
26373    function clearErr(){
26374      if(errBox)errBox.classList.remove('show');
26375      if(okBox)okBox.classList.remove('show');
26376    }
26377    function validate(){
26378      var val=inp?inp.value.trim():'';
26379      clearErr();
26380      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26381      if(submitBtn)submitBtn.disabled=false;
26382      if(warning){
26383        var name=basename(val);
26384        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26385        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26386        else warning.classList.remove('show');
26387      }
26388    }
26389    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26390    if(browseBtn){
26391      browseBtn.addEventListener('click',function(){
26392        browseBtn.disabled=true;browseBtn.textContent='...';
26393        fetch('/pick-directory')
26394          .then(function(r){return r.ok?r.json():{cancelled:true};})
26395          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26396          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26397      });
26398    }
26399    if(submitBtn){
26400      submitBtn.addEventListener('click',function(){
26401        var folder=inp?inp.value.trim():'';
26402        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26403        clearErr();
26404        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26405        var body=new URLSearchParams();
26406        body.set('file_path',folder);
26407        body.set('redirect_url',redirectUrl);
26408        body.set('expected_run_id',runId);
26409        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26410          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26411          .then(function(d){
26412            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26413            if(d&&d.ok){
26414              if(okBox)okBox.classList.add('show');
26415              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26416            } else {
26417              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26418            }
26419          })
26420          .catch(function(e){
26421            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26422            showErr('Network error: '+String(e));
26423          });
26424      });
26425    }
26426  })();</script>
26427  <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>
26428</body>
26429</html>
26430"##,
26431    ext = "html"
26432)]
26433struct LocateFileTemplate {
26434    run_id: String,
26435    artifact_type: String,
26436    expected_filename: String,
26437    server_mode: bool,
26438    csp_nonce: String,
26439    version: &'static str,
26440}
26441
26442// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26443
26444#[derive(Template)]
26445#[template(
26446    source = r##"
26447<!doctype html>
26448<html lang="en">
26449<head>
26450  <meta charset="utf-8">
26451  <meta name="viewport" content="width=device-width, initial-scale=1">
26452  <title>OxideSLOC | Locate Scan Files</title>
26453  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26454  <style nonce="{{ csp_nonce }}">
26455    :root {
26456      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26457      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26458      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26459      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26460    }
26461    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26462    *{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;}
26463    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26464    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26465    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26466    .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);}
26467    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26468    .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));}
26469    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26470    .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;}
26471    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26472    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26473    @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;}}
26474    .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;}
26475    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26476    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26477    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26478    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26479    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26480    .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;}
26481    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26482    .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);}
26483    .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;}
26484    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26485    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26486    .settings-modal-body{padding:14px 16px 16px;}
26487    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26488    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26489    .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;}
26490    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26491    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26492    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26493    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26494    .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;}
26495    .tz-select:focus{border-color:var(--oxide);}
26496    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26497    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26498    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26499    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26500    .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;}
26501    .error-box.hidden{display:none;}
26502    .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;}
26503    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26504    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26505    .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;}
26506    .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;}
26507    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26508    .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;}
26509    .btn-secondary:hover{background:var(--line);}
26510    .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;}
26511    .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;}
26512    .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;}
26513    @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));}}
26514    .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;}
26515    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26516    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26517    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26518    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26519    .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;}
26520    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26521    body.dark-theme .relocate-input{background:var(--surface-2);}
26522  </style>
26523</head>
26524<body>
26525  <div class="background-watermarks" aria-hidden="true">
26526    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26527    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26528    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26529    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26530    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26531    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26532  </div>
26533  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26534  <div class="top-nav">
26535    <div class="top-nav-inner">
26536      <a class="brand" href="/">
26537        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26538        <div class="brand-copy">
26539          <div class="brand-title">OxideSLOC</div>
26540          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26541        </div>
26542      </a>
26543      <div class="nav-right">
26544        <a class="nav-pill" href="/">Home</a>
26545        <div class="nav-dropdown">
26546          <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>
26547          <div class="nav-dropdown-menu">
26548            <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>
26549          </div>
26550        </div>
26551        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26552        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26553        <div class="nav-dropdown">
26554          <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>
26555          <div class="nav-dropdown-menu">
26556            <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>
26557          </div>
26558        </div>
26559        <div class="server-status-wrap" id="server-status-wrap">
26560          <div class="nav-pill server-online-pill" id="server-status-pill">
26561            <span class="status-dot" id="status-dot"></span>
26562            <span id="server-status-label">Server</span>
26563            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26564          </div>
26565          <div class="server-status-tip">
26566            OxideSLOC is running — accessible on your network.
26567            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26568          </div>
26569        </div>
26570        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26571          <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>
26572        </button>
26573        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26574          <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>
26575          <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>
26576        </button>
26577      </div>
26578    </div>
26579  </div>
26580
26581  <div class="page">
26582    <div class="panel">
26583      <h1>Scan Files Moved</h1>
26584      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26585      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26586      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26587      <div class="relocate-section">
26588        <h2>Locate Scan Output</h2>
26589        <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>
26590        <div class="relocate-row">
26591          <input type="text" id="relocate-folder" name="folder_path"
26592                 value="{{ folder_hint }}"
26593                 placeholder="Path to folder containing scan output..."
26594                 class="relocate-input" autocomplete="off" spellcheck="false">
26595          {% if !server_mode %}
26596          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
26597          {% endif %}
26598        </div>
26599        <div style="margin-top:12px;">
26600          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26601        </div>
26602      </div>
26603      <div class="actions">
26604        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26605        <a class="btn-secondary" href="/view-reports">View Reports</a>
26606      </div>
26607    </div>
26608  </div>
26609  <footer class="site-footer">
26610    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
26611    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26612    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26613    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26614    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26615  </footer>
26616  <script nonce="{{ csp_nonce }}">
26617    (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");});})();
26618    (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);}})();
26619    (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;});})();
26620  </script>
26621  <script nonce="{{ csp_nonce }}">
26622  (function(){
26623    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'}];
26624    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);});}
26625    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26626    function init(){
26627      var btn=document.getElementById('settings-btn');if(!btn)return;
26628      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26629      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>';
26630      document.body.appendChild(m);
26631      var g=document.getElementById('scheme-grid');
26632      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);});
26633      var cl=document.getElementById('settings-close');
26634      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);});})();
26635      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');});
26636      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26637      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26638    }
26639    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26640  }());
26641  (function(){
26642    var browseBtn=document.getElementById('browse-relocate-btn');
26643    if(browseBtn){
26644      browseBtn.addEventListener('click',function(){
26645        browseBtn.disabled=true;browseBtn.textContent='...';
26646        var inp=document.getElementById('relocate-folder');
26647        var hint=inp?inp.value:'';
26648        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
26649          .then(function(r){return r.ok?r.json():{cancelled:true};})
26650          .then(function(d){
26651            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26652            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26653          })
26654          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26655      });
26656    }
26657    var restoreBtn=document.getElementById('restore-btn');
26658    var errBox=document.getElementById('relocate-error-box');
26659    var okBox=document.getElementById('relocate-success-box');
26660    if(restoreBtn){
26661      restoreBtn.addEventListener('click',function(){
26662        var inp=document.getElementById('relocate-folder');
26663        var folder=inp?inp.value.trim():'';
26664        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
26665        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
26666        var body=new URLSearchParams();
26667        body.set('run_id','{{ run_id }}');
26668        body.set('redirect_url','{{ redirect_url }}');
26669        body.set('folder_path',folder);
26670        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26671          .then(function(r){return r.json();})
26672          .then(function(d){
26673            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26674            if(d&&d.ok){
26675              if(errBox)errBox.classList.add('hidden');
26676              if(okBox){okBox.style.display='block';}
26677              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
26678            } else {
26679              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
26680            }
26681          })
26682          .catch(function(e){
26683            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
26684            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
26685          });
26686      });
26687    }
26688  }());
26689  </script>
26690  <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]';
26691  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;}
26692  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>
26693</body>
26694</html>
26695"##,
26696    ext = "html"
26697)]
26698struct RelocateScanTemplate {
26699    message: String,
26700    run_id: String,
26701    folder_hint: String,
26702    redirect_url: String,
26703    server_mode: bool,
26704    csp_nonce: String,
26705    version: &'static str,
26706}
26707
26708// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
26709
26710#[derive(Template)]
26711#[template(
26712    source = r##"
26713<!doctype html>
26714<html lang="en">
26715<head>
26716  <meta charset="utf-8">
26717  <meta name="viewport" content="width=device-width, initial-scale=1">
26718  <title>OxideSLOC | View Reports</title>
26719  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26720  <style nonce="{{ csp_nonce }}">
26721    :root {
26722      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
26723      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26724      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26725      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26726      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
26727    }
26728    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; }
26729    *{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;}
26730    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26731    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26732    .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);}
26733    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26734    .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));}
26735    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26736    .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;}
26737    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26738    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26739    @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; } }
26740    .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;}
26741    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26742    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26743    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26744    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26745    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26746    .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;}
26747    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26748    .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);}
26749    .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;}
26750    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26751    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26752    .settings-modal-body{padding:14px 16px 16px;}
26753    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26754    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26755    .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;}
26756    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26757    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26758    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26759    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26760    .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;}
26761    .tz-select:focus{border-color:var(--oxide);}
26762    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
26763    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26764    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
26765    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
26766    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
26767    .panel-meta{font-size:13px;color:var(--muted);}
26768    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
26769    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
26770    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
26771    .per-page-label{font-size:13px;color:var(--muted);}
26772    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;}
26773    .filter-input{min-width:180px;cursor:text;}
26774    .table-wrap{width:100%;overflow-x:auto;}
26775    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
26776    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;}
26777    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
26778    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
26779    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
26780    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
26781    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
26782    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26783    tr:last-child td{border-bottom:none;}
26784    tr:hover td{background:var(--surface-2);}
26785    .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);}
26786    .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);}
26787    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
26788    .metric-num{font-weight:700;color:var(--text);}
26789    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
26790    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
26791    .git-commit-chip{cursor:help;}
26792    .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;}
26793    .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;}
26794    .btn:hover{background:var(--line);}
26795    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26796    .btn.primary:hover{opacity:.9;}
26797    .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;}
26798    .btn-back:hover{background:var(--line);}
26799    .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;}
26800    .export-btn:hover{background:var(--line);}
26801    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
26802    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
26803    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
26804    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
26805    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
26806    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
26807    .pagination-info{font-size:13px;color:var(--muted);}
26808    .pagination-btns{display:flex;gap:6px;}
26809    .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;}
26810    .pg-btn:hover:not(:disabled){background:var(--line);}
26811    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
26812    .pg-btn:disabled{opacity:.35;cursor:default;}
26813    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
26814    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26815    .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);}
26816    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
26817    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
26818    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
26819    .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);}
26820    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
26821    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
26822    .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;}
26823    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26824    .site-footer a{color:var(--muted);}
26825    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
26826    .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%;}
26827    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
26828    .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;}
26829    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
26830    .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;}
26831    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
26832    .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;}
26833    .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;}
26834    .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;}
26835    @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));}}
26836    .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;}
26837    .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;}
26838    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
26839    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
26840    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
26841    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
26842    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
26843    .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;}
26844    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26845    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
26846    .watched-chip-rm:hover{color:var(--oxide);}
26847    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
26848    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
26849    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
26850    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
26851    .rpt-btn{min-width:58px;justify-content:center;}
26852    .flex-row{display:flex;align-items:center;gap:8px;}
26853    .report-cell{overflow:visible;white-space:normal;}
26854    #history-table col:nth-child(1){width:185px;}
26855    #history-table col:nth-child(2){width:220px;}
26856    #history-table col:nth-child(3){width:100px;}
26857    #history-table col:nth-child(4){width:72px;}
26858    #history-table col:nth-child(5){width:82px;}
26859    #history-table col:nth-child(6){width:82px;}
26860    #history-table col:nth-child(7){width:65px;}
26861    #history-table col:nth-child(8){width:90px;}
26862    #history-table col:nth-child(9){width:85px;}
26863    #history-table col:nth-child(10){width:115px;}
26864    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
26865    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
26866    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
26867    .submod-details summary::-webkit-details-marker{display:none;}
26868.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
26869    .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;}
26870    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
26871    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
26872  </style>
26873</head>
26874<body>
26875  <div class="background-watermarks" aria-hidden="true">
26876    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26877    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26878    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26879    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26880    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26881    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26882  </div>
26883  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26884  <div class="top-nav">
26885    <div class="top-nav-inner">
26886      <a class="brand" href="/">
26887        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26888        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
26889      </a>
26890      <div class="nav-right">
26891        <a class="nav-pill" href="/">Home</a>
26892        <div class="nav-dropdown">
26893          <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>
26894          <div class="nav-dropdown-menu">
26895            <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>
26896          </div>
26897        </div>
26898        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26899        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26900        <div class="nav-dropdown">
26901          <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>
26902          <div class="nav-dropdown-menu">
26903            <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>
26904          </div>
26905        </div>
26906        <div class="server-status-wrap" id="server-status-wrap">
26907          <div class="nav-pill server-online-pill" id="server-status-pill">
26908            <span class="status-dot" id="status-dot"></span>
26909            <span id="server-status-label">Server</span>
26910            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26911          </div>
26912          <div class="server-status-tip">
26913            OxideSLOC is running — accessible on your network.
26914            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26915          </div>
26916        </div>
26917        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26918          <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>
26919        </button>
26920        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26921          <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>
26922          <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>
26923        </button>
26924      </div>
26925    </div>
26926  </div>
26927
26928  <div class="page">
26929    {% if let Some(err) = browse_error %}
26930    <div class="toast-error">
26931      <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>
26932      {{ err }}
26933    </div>
26934    {% endif %}
26935    {% if linked_count > 0 %}
26936    <div class="toast-success">
26937      <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>
26938      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
26939    </div>
26940    {% endif %}
26941    <div class="watched-bar">
26942      <div class="watched-bar-left">
26943        <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>
26944        <span class="watched-label">Watched Folders</span>
26945        <div class="watched-chips">
26946          {% if server_mode %}
26947          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
26948          {% else %}
26949          {% for dir in watched_dirs %}
26950          <span class="watched-chip">
26951            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
26952            <form method="POST" action="/watched-dirs/remove" style="display:contents">
26953              <input type="hidden" name="folder_path" value="{{ dir }}">
26954              <input type="hidden" name="redirect_to" value="/view-reports">
26955              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
26956            </form>
26957          </span>
26958          {% endfor %}
26959          {% if watched_dirs.is_empty() %}
26960          <span class="watched-none">No folders watched — click Choose to add one</span>
26961          {% endif %}
26962          {% endif %}
26963        </div>
26964      </div>
26965      {% if !server_mode %}
26966      <div class="watched-bar-right">
26967        <button type="button" class="btn" id="add-watched-btn">
26968          <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>
26969          Choose
26970        </button>
26971        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
26972          <input type="hidden" name="redirect_to" value="/view-reports">
26973          <button type="submit" class="btn">&#8635; Refresh</button>
26974        </form>
26975      </div>
26976      {% endif %}
26977    </div>
26978    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
26979      <div class="scan-overlay-card">
26980        <div class="scan-spinner"></div>
26981        <div class="scan-overlay-text">Scanning folder…</div>
26982        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
26983      </div>
26984    </div>
26985    <style>
26986    .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);}
26987    .scan-overlay.active{display:flex;}
26988    .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;}
26989    .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;}
26990    @keyframes scanSpin{to{transform:rotate(360deg);}}
26991    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
26992    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
26993    </style>
26994    {% if total_scans > 0 %}
26995    <div class="summary-strip">
26996      <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>
26997      <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>
26998      <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>
26999      <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>
27000    </div>
27001    {% endif %}
27002
27003    <section class="panel">
27004      <div class="panel-header">
27005        <div>
27006          <h1>View Reports</h1>
27007          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27008          {% 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 %}
27009        </div>
27010        <div class="flex-row">
27011          <button type="button" class="export-btn" id="export-csv-btn">
27012            <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>
27013            Export CSV
27014          </button>
27015          <button type="button" class="export-btn" id="export-xls-btn">
27016            <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>
27017            Export Excel
27018          </button>
27019        </div>
27020      </div>
27021
27022      {% if entries.is_empty() %}
27023      <div class="empty-state">
27024        <strong>No reports with viewable HTML yet</strong>
27025        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.
27026      </div>
27027      {% else %}
27028      <div class="filter-row">
27029        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27030        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27031        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27032      </div>
27033      <div class="table-wrap">
27034        <table id="history-table">
27035          <colgroup>
27036            <col><col><col><col><col><col><col><col><col><col>
27037          </colgroup>
27038          <thead>
27039            <tr id="history-thead">
27040              <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>
27041              <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>
27042              <th>Run ID<div class="col-resize-handle"></div></th>
27043              <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>
27044              <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>
27045              <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>
27046              <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>
27047              <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>
27048              <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>
27049              <th>Report<div class="col-resize-handle"></div></th>
27050            </tr>
27051          </thead>
27052          <tbody id="history-tbody">
27053            {% for entry in entries %}
27054            <tr class="history-row" data-run="{{ entry.run_id }}"
27055                data-timestamp="{{ entry.timestamp }}"
27056                data-project="{{ entry.project_label }}"
27057                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27058                data-skipped="{{ entry.files_skipped }}"
27059                data-comments="{{ entry.comment_lines }}"
27060                data-blank="{{ entry.blank_lines }}"
27061                data-physical="{{ entry.total_physical_lines }}"
27062                data-functions="{{ entry.functions }}"
27063                data-classes="{{ entry.classes }}"
27064                data-variables="{{ entry.variables }}"
27065                data-imports="{{ entry.imports }}"
27066                data-tests="{{ entry.test_count }}"
27067                data-branch="{{ entry.git_branch }}"
27068                data-commit="{{ entry.git_commit }}"
27069                data-has-json="{{ entry.has_json }}"
27070                data-html-url="/runs/html/{{ entry.run_id }}">
27071              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27072              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27073              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27074              <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>
27075              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27076              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27077              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27078              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
27079              <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>
27080              <td class="report-cell">
27081                <div class="actions-cell">
27082                  {% 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 %}
27083                  {% 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 %}
27084                </div>
27085                {% if !entry.submodule_links.is_empty() %}
27086                <details class="submod-details">
27087                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
27088                  <div class="submod-link-list">
27089                    {% for sub in entry.submodule_links %}
27090                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27091                    {% endfor %}
27092                  </div>
27093                </details>
27094                {% endif %}
27095              </td>
27096            </tr>
27097            {% endfor %}
27098          </tbody>
27099        </table>
27100      </div>
27101      <div class="pagination">
27102        <span class="pagination-info" id="pagination-info"></span>
27103        <div class="pagination-btns" id="pagination-btns"></div>
27104        <div class="flex-row">
27105          <span class="per-page-label">Show</span>
27106          <select class="per-page" id="per-page-sel">
27107            <option value="10">10 per page</option>
27108            <option value="25" selected>25 per page</option>
27109            <option value="50">50 per page</option>
27110            <option value="100">100 per page</option>
27111          </select>
27112          <span class="per-page-label" id="page-range-label"></span>
27113        </div>
27114      </div>
27115      {% endif %}
27116    </section>
27117  </div>
27118
27119  <footer class="site-footer">
27120    local code analysis - metrics, history and reports
27121    &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>
27122    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27123    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27124    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27125    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27126  </footer>
27127
27128  <script nonce="{{ csp_nonce }}">
27129    (function () {
27130      // ── Theme ──────────────────────────────────────────────────────────────
27131      var storageKey = 'oxide-sloc-theme';
27132      var body = document.body;
27133      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27134      var toggle = document.getElementById('theme-toggle');
27135      if (toggle) toggle.addEventListener('click', function () {
27136        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27137        body.classList.toggle('dark-theme', next === 'dark');
27138        try { localStorage.setItem(storageKey, next); } catch(e) {}
27139      });
27140
27141      // ── State ─────────────────────────────────────────────────────────────
27142      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27143      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27144      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27145
27146      // Aggregate stats from first (most recent) row
27147      if (allRows.length) {
27148        var first = allRows[0];
27149        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();}
27150        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>':'');}
27151        setChipVal('agg-code', first.dataset.code);
27152        setChipVal('agg-files', first.dataset.files);
27153        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27154        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27155        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(); });
27156      }
27157
27158      // ── Branch filter population ──────────────────────────────────────────
27159      (function() {
27160        var branches = {};
27161        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27162        var sel = document.getElementById('branch-filter');
27163        if (sel) Object.keys(branches).sort().forEach(function(b) {
27164          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27165        });
27166      })();
27167
27168      // ── Filter ────────────────────────────────────────────────────────────
27169      function getFilteredRows() {
27170        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27171        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27172        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27173          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27174          if (branch && (r.dataset.branch || '') !== branch) return false;
27175          return true;
27176        });
27177      }
27178
27179      // ── Pagination ────────────────────────────────────────────────────────
27180      function renderPage() {
27181        var filtered = getFilteredRows();
27182        var total = filtered.length;
27183        var totalPages = Math.max(1, Math.ceil(total / perPage));
27184        currentPage = Math.min(currentPage, totalPages);
27185        var start = (currentPage - 1) * perPage;
27186        var end = Math.min(start + perPage, total);
27187        var shown = {};
27188        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27189        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27190          r.style.display = shown[r.dataset.run] ? '' : 'none';
27191        });
27192        var rl = document.getElementById('page-range-label');
27193        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27194        var info = document.getElementById('pagination-info');
27195        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27196        var btns = document.getElementById('pagination-btns');
27197        if (!btns) return;
27198        btns.innerHTML = '';
27199        function makeBtn(lbl, pg, active, disabled) {
27200          var b = document.createElement('button');
27201          b.className = 'pg-btn' + (active ? ' active' : '');
27202          b.textContent = lbl; b.disabled = disabled;
27203          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27204          return b;
27205        }
27206        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27207        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27208        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27209        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27210      }
27211
27212      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27213      window.applyFilters = function() { currentPage = 1; renderPage(); };
27214
27215      // ── Sorting ───────────────────────────────────────────────────────────
27216      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27217      function doSort(col, type, order) {
27218        var tbody = document.getElementById('history-tbody');
27219        if (!tbody) return;
27220        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27221        rows.sort(function(a, b) {
27222          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27223          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27224          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27225          return va < vb ? 1 : va > vb ? -1 : 0;
27226        });
27227        rows.forEach(function(r) { tbody.appendChild(r); });
27228        currentPage = 1; renderPage();
27229      }
27230      sortHeaders.forEach(function(th) {
27231        th.addEventListener('click', function(e) {
27232          if (e.target.classList.contains('col-resize-handle')) return;
27233          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27234          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27235          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27236          th.classList.add('sort-' + sortOrder);
27237          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27238          doSort(col, type, sortOrder);
27239        });
27240      });
27241
27242      // ── Column resize ─────────────────────────────────────────────────────
27243      (function() {
27244        var table = document.getElementById('history-table');
27245        if (!table) return;
27246        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27247        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27248        ths.forEach(function(th, i) {
27249          var handle = th.querySelector('.col-resize-handle');
27250          if (!handle || !cols[i]) return;
27251          var startX, startW;
27252          handle.addEventListener('mousedown', function(e) {
27253            e.stopPropagation(); e.preventDefault();
27254            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27255            handle.classList.add('dragging');
27256            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27257            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27258            document.addEventListener('mousemove', onMove);
27259            document.addEventListener('mouseup', onUp);
27260          });
27261        });
27262      })();
27263
27264      // ── Full-commit hover tooltip ─────────────────────────────────────────
27265      // The commit chips live inside an overflow:auto table wrapper, which would
27266      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27267      // (escaping the scroll container) and follow the cursor. Event delegation
27268      // keeps it working after pagination/sorting re-renders the rows.
27269      (function() {
27270        var tip = document.createElement('div');
27271        tip.className = 'commit-tip';
27272        tip.setAttribute('role', 'tooltip');
27273        document.body.appendChild(tip);
27274        var shown = false;
27275        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27276        function place(e) {
27277          var pad = 14, r = tip.getBoundingClientRect();
27278          var x = e.clientX + pad, y = e.clientY + pad;
27279          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27280          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27281          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27282        }
27283        function hide() { tip.style.display = 'none'; shown = false; }
27284        document.addEventListener('mouseover', function(e) {
27285          var chip = chipFrom(e.target);
27286          if (!chip) return;
27287          var full = chip.getAttribute('data-full-commit');
27288          if (!full) return;
27289          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27290        });
27291        document.addEventListener('mousemove', function(e) {
27292          if (!shown) return;
27293          if (chipFrom(e.target)) place(e); else hide();
27294        });
27295        document.addEventListener('mouseout', function(e) {
27296          if (chipFrom(e.target)) hide();
27297        });
27298      })();
27299
27300      // ── Reset view ────────────────────────────────────────────────────────
27301      window.resetView = function() {
27302        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27303        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27304        sortCol = null; sortOrder = 'asc';
27305        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27306        var tbody = document.getElementById('history-tbody');
27307        if (tbody) {
27308          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27309          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27310          rows.forEach(function(r) { tbody.appendChild(r); });
27311        }
27312        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27313        var table = document.getElementById('history-table');
27314        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27315        currentPage = 1; renderPage();
27316      };
27317
27318      renderPage();
27319
27320      // ── Export helpers ────────────────────────────────────────────────────
27321      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
27322      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27323      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);}
27324      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;');}
27325      function slocXlsx(fname,sheet,hdrs,rows){
27326        var enc=new TextEncoder();
27327        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;}
27328        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;}
27329        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27330        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27331        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27332        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;}
27333        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27334        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];}
27335        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27336        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27337        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27338          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27339          +'<fonts count="2">'
27340            +'<font><sz val="11"/><name val="Calibri"/></font>'
27341            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27342          +'</fonts>'
27343          +'<fills count="3">'
27344            +'<fill><patternFill patternType="none"/></fill>'
27345            +'<fill><patternFill patternType="gray125"/></fill>'
27346            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27347          +'</fills>'
27348          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27349          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27350          +'<cellXfs count="4">'
27351            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27352            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27353            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27354            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27355          +'</cellXfs>'
27356          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27357          +'</styleSheet>';
27358        var rx='<row r="1">';
27359        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27360        rx+='</row>';
27361        rows.forEach(function(row,ri){
27362          var rn=ri+2;rx+='<row r="'+rn+'">';
27363          row.forEach(function(cell,c){
27364            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27365            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27366            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27367            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27368            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27369            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27370          });
27371          rx+='</row>';
27372        });
27373        var lastCol=hdrs.length,lastRow=rows.length+1;
27374        var tableRef='A1:'+colNm(lastCol)+lastRow;
27375        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27376          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27377          +'<autoFilter ref="'+tableRef+'"/>'
27378          +'<tableColumns count="'+lastCol+'">'
27379          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27380          +'</tableColumns>'
27381          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27382          +'</table>';
27383        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27384          +'<Relationships xmlns="'+pns+'relationships">'
27385          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27386          +'</Relationships>';
27387        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>';
27388        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27389          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27390          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27391          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27392          +'</worksheet>';
27393        var F={
27394          '[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>',
27395          '_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>',
27396          '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>',
27397          '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>',
27398          'xl/styles.xml':stl,
27399          'xl/sharedStrings.xml':ssXml,
27400          'xl/worksheets/sheet1.xml':sh,
27401          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27402          'xl/tables/table1.xml':tableXml
27403        };
27404        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'];
27405        var zparts=[],zcds=[],zoff=0,znf=0;
27406        order.forEach(function(name){
27407          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27408          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]);
27409          var entry=new Uint8Array(lha.length+nb.length+sz);
27410          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27411          zparts.push(entry);
27412          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));
27413          var cde=new Uint8Array(cda.length+nb.length);
27414          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27415          zcds.push(cde);zoff+=entry.length;znf++;
27416        });
27417        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27418        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]);
27419        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27420        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27421        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27422        zout.set(new Uint8Array(ea),zpos);
27423        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27424      }
27425
27426      // Multi-sheet XLSX builder for the scan-history export.
27427      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27428      function slocXlsxMulti(fname,sheets){
27429        var enc=new TextEncoder();
27430        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;}
27431        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;}
27432        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27433        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27434        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27435        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];}
27436        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;}
27437        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27438        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27439        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27440          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27441          +'<fonts count="3">'
27442            +'<font><sz val="11"/><name val="Calibri"/></font>'
27443            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27444            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27445          +'</fonts>'
27446          +'<fills count="4">'
27447            +'<fill><patternFill patternType="none"/></fill>'
27448            +'<fill><patternFill patternType="gray125"/></fill>'
27449            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27450            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27451          +'</fills>'
27452          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27453          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27454          +'<cellXfs count="7">'
27455            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27456            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27457            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27458            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27459            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27460            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27461            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27462          +'</cellXfs>'
27463          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27464          +'</styleSheet>';
27465        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27466        sheets.forEach(function(sh,sheetIdx){
27467          var rx='<row r="1">';
27468          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27469          rx+='</row>';
27470          var rn=2;
27471          sh.rows.forEach(function(row){
27472            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27473            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27474              rx+='<row r="'+rn+'">';
27475              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27476              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27477              rx+='</row>';rn++;return;
27478            }
27479            rx+='<row r="'+rn+'">';
27480            row.forEach(function(cell,c){
27481              var ref=colRef(c,rn);
27482              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27483              if(typeof cell==='object'&&cell!==null){
27484                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27485                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27486                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27487                return;
27488              }
27489              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27490              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27491            });
27492            rx+='</row>';rn++;
27493          });
27494          var cw='';
27495          if(sh.colWidths&&sh.colWidths.length>0){
27496            cw='<cols>';
27497            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27498            cw+='</cols>';
27499          }
27500          var tblParts='';
27501          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27502            tableCounter++;
27503            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27504            var tRef='A1:'+colNm(colCount)+rowCount;
27505            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27506              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27507              +'<autoFilter ref="'+tRef+'"/>'
27508              +'<tableColumns count="'+colCount+'">'
27509              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27510              +'</tableColumns>'
27511              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27512              +'</table>';
27513            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27514              +'<Relationships xmlns="'+pns+'relationships">'
27515              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27516              +'</Relationships>';
27517            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27518          }
27519          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27520            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27521            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27522        });
27523        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>';
27524        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27525        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27526        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>';
27527        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27528        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27529        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27530        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27531          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27532        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27533        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};
27534        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27535        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27536        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27537        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27538        var zparts=[],zcds=[],zoff=0,znf=0;
27539        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++;});
27540        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27541        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]);
27542        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27543        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27544        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27545        zout.set(new Uint8Array(ea),zpos);
27546        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27547      }
27548
27549      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'};
27550      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27551
27552      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'];
27553      function getHistoryRows(){
27554        var r=[];
27555        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27556          var code=Number(tr.getAttribute('data-code'))||0;
27557          var phys=Number(tr.getAttribute('data-physical'))||0;
27558          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27559          r.push([
27560            tr.getAttribute('data-timestamp')||'',
27561            tr.getAttribute('data-project')||'',
27562            tr.getAttribute('data-run')||'',
27563            tr.getAttribute('data-physical')||'',
27564            tr.getAttribute('data-code')||'',
27565            tr.getAttribute('data-comments')||'',
27566            tr.getAttribute('data-blank')||'',
27567            tr.getAttribute('data-files')||'',
27568            tr.getAttribute('data-skipped')||'',
27569            tr.getAttribute('data-functions')||'',
27570            tr.getAttribute('data-classes')||'',
27571            tr.getAttribute('data-variables')||'',
27572            tr.getAttribute('data-imports')||'',
27573            tr.getAttribute('data-tests')||'',
27574            dens,
27575            tr.getAttribute('data-branch')||'',
27576            tr.getAttribute('data-commit')||''
27577          ]);
27578        });
27579        return r;
27580      }
27581      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27582      window.exportHistoryXls = function(){
27583        var histRows=getHistoryRows();
27584        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27585        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]];});
27586        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]};
27587        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27588        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27589        var runId=jsonRow.getAttribute('data-run')||'';
27590        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27591        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27592        fetch('/runs/json/'+runId)
27593          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27594          .then(function(run){
27595            var tot=run.summary_totals||{};
27596            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27597            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27598            function B(v){return{v:v,s:4};}
27599            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27600            var sumRows=[
27601              [{_sec:true,v:'RUN INFORMATION'}],
27602              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27603              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27604              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27605              [B('Branch'),run.git_branch||''],
27606              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27607              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27608              [B('Files Analyzed'),N(tot.files_analyzed)],
27609              [B('Files Skipped'),N(tot.files_skipped)],
27610              [],
27611              [{_sec:true,v:'CODE METRICS'}],
27612              [B('Physical Lines'),N(phys)],
27613              [B('Code Lines'),N(code)],
27614              [B('Comments'),N(tot.comment_lines)],
27615              [B('Blank Lines'),N(tot.blank_lines)],
27616              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27617              [B('Functions'),N(tot.functions)],
27618              [B('Classes / Types'),N(tot.classes)],
27619              [B('Variables'),N(tot.variables)],
27620              [B('Imports'),N(tot.imports)],
27621              [B('Tests'),N(tot.test_count)],
27622              [B('Assertions'),N(tot.test_assertion_count)],
27623              [B('Test Suites'),N(tot.test_suite_count)],
27624              [B('Code Density'),{v:dens,s:6}],
27625              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27626            ];
27627            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27628            var langRows=(run.totals_by_language||[]).map(function(l){
27629              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27630              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27631              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];
27632            });
27633            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27634            var pfRows=(run.per_file_records||[]).map(function(r){
27635              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27636              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];
27637            });
27638            var skHdrs=['File','Status','Size (bytes)'];
27639            var skRows=(run.skipped_file_records||[]).map(function(r){
27640              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27641            });
27642            slocXlsxMulti('scan-history.xlsx',[
27643              histSheet,
27644              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27645              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27646              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27647              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27648            ]);
27649          })
27650          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27651      };
27652
27653      var csvBtn = document.getElementById('export-csv-btn');
27654      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27655      var xlsBtn = document.getElementById('export-xls-btn');
27656      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27657
27658      // ── Remaining CSP-safe event bindings ────────────────────────────────
27659      (function wireEvents() {
27660        var el;
27661        el = document.getElementById('reset-view-btn');
27662        if (el) el.addEventListener('click', window.resetView);
27663        el = document.getElementById('project-filter');
27664        if (el) el.addEventListener('input', window.applyFilters);
27665        el = document.getElementById('branch-filter');
27666        if (el) el.addEventListener('change', window.applyFilters);
27667        el = document.getElementById('per-page-sel');
27668        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
27669        (function(){
27670          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');};
27671          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);
27672        })();
27673        el = document.getElementById('add-watched-btn');
27674        if (el) el.addEventListener('click', function() {
27675          fetch('/pick-directory?kind=reports')
27676            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
27677            .then(function(data) {
27678              if (!data.cancelled && data.selected_path) {
27679                var form = document.createElement('form');
27680                form.method = 'POST';
27681                form.action = '/watched-dirs/add';
27682                var ri = document.createElement('input');
27683                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
27684                var fi = document.createElement('input');
27685                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
27686                form.appendChild(ri); form.appendChild(fi);
27687                document.body.appendChild(form);
27688                if (window.__scanOverlay) window.__scanOverlay();
27689                form.submit();
27690              }
27691            })
27692            .catch(function(e) { alert('Could not open folder picker: ' + e); });
27693        });
27694      })();
27695
27696      (function randomizeWatermarks() {
27697        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
27698        if (!wms.length) return;
27699        var placed = [];
27700        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;}
27701        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];}
27702        var half=Math.floor(wms.length/2);
27703        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;});
27704      })();
27705
27706      (function spawnCodeParticles() {
27707        var container = document.getElementById('code-particles');
27708        if (!container) return;
27709        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'];
27710        for (var i = 0; i < 38; i++) {
27711          (function(idx) {
27712            var el = document.createElement('span');
27713            el.className = 'code-particle';
27714            el.textContent = snippets[idx % snippets.length];
27715            var left = Math.random() * 94 + 2;
27716            var top = Math.random() * 88 + 6;
27717            var dur = (Math.random() * 10 + 9).toFixed(1);
27718            var delay = (Math.random() * 18).toFixed(1);
27719            var rot = (Math.random() * 26 - 13).toFixed(1);
27720            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
27721            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';
27722            container.appendChild(el);
27723          })(i);
27724        }
27725      })();
27726    })();
27727  </script>
27728  <script nonce="{{ csp_nonce }}">
27729  (function(){
27730    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'}];
27731    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);});}
27732    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27733    function init(){
27734      var btn=document.getElementById('settings-btn');if(!btn)return;
27735      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27736      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>';
27737      document.body.appendChild(m);
27738      var g=document.getElementById('scheme-grid');
27739      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);});
27740      var cl=document.getElementById('settings-close');
27741      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);});})();
27742      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');});
27743      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27744      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27745    }
27746    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27747  }());
27748  </script>
27749  <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>
27750</body>
27751</html>
27752"##,
27753    ext = "html"
27754)]
27755struct HistoryTemplate {
27756    version: &'static str,
27757    entries: Vec<HistoryEntryRow>,
27758    total_scans: usize,
27759    linked_count: usize,
27760    browse_error: Option<String>,
27761    watched_dirs: Vec<String>,
27762    csp_nonce: String,
27763    server_mode: bool,
27764}
27765
27766// ── CompareSelectTemplate ──────────────────────────────────────────────────────
27767
27768#[derive(Template)]
27769#[template(
27770    source = r##"
27771<!doctype html>
27772<html lang="en">
27773<head>
27774  <meta charset="utf-8">
27775  <meta name="viewport" content="width=device-width, initial-scale=1">
27776  <title>OxideSLOC | Compare Scans</title>
27777  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27778  <style nonce="{{ csp_nonce }}">
27779    :root {
27780      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27781      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27782      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27783      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27784      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
27785    }
27786    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
27787    *{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;}
27788    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27789    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27790    .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);}
27791    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27792    .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));}
27793    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27794    .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;}
27795    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27796    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27797    @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; } }
27798    .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;}
27799    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27800    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27801    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27802    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27803    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27804    .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;}
27805    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27806    .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);}
27807    .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;}
27808    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27809    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27810    .settings-modal-body{padding:14px 16px 16px;}
27811    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27812    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27813    .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;}
27814    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27815    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27816    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27817    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27818    .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;}
27819    .tz-select:focus{border-color:var(--oxide);}
27820    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27821    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27822    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27823    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27824    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27825    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
27826    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
27827    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27828    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27829    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27830    .per-page-label{font-size:13px;color:var(--muted);}
27831    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;}
27832    .filter-input{min-width:180px;cursor:text;}
27833    .table-wrap{width:100%;overflow-x:auto;}
27834    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
27835    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;}
27836    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27837    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27838    #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;}
27839    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
27840    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
27841    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
27842    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
27843    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
27844    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
27845    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
27846    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
27847    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
27848    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27849    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27850    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27851    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27852    tr:last-child td{border-bottom:none;}
27853    tr.selected td{background:var(--sel-bg);}
27854    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
27855    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
27856    tr{cursor:pointer;}
27857    tr.row-locked{opacity:.35;cursor:not-allowed;}
27858    tr.row-locked td{pointer-events:none;}
27859    .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;}
27860    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
27861    .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;}
27862    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
27863    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
27864    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
27865    .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);}
27866    .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);}
27867    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27868    .metric-num{font-weight:700;color:var(--text);}
27869    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
27870    .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;}
27871    .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;}
27872    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
27873    .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;}
27874    .btn:hover{background:var(--line);}
27875    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
27876    .btn.primary:hover{opacity:.9;}
27877    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
27878    .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;}
27879    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27880    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27881    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27882    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27883    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27884    .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;}
27885    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27886    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27887    .watched-chip-rm:hover{color:var(--oxide);}
27888    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27889    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27890    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27891    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27892    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
27893    .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;}
27894    .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;}
27895    .btn-back:hover{background:var(--line);}
27896    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27897    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27898    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27899    .pagination-info{font-size:13px;color:var(--muted);}
27900    .pagination-btns{display:flex;gap:6px;}
27901    .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;}
27902    .pg-btn:hover:not(:disabled){background:var(--line);}
27903    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27904    .pg-btn:disabled{opacity:.35;cursor:default;}
27905    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
27906    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27907    .site-footer a{color:var(--muted);}
27908    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27909    .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;}
27910    .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;}
27911    .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;}
27912    @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));}}
27913    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27914    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27915    .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);}
27916    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27917    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27918    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27919    .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);}
27920    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27921    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27922    .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;}
27923    .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;}
27924    .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%;}
27925    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
27926    .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;}
27927    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
27928    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
27929    .hidden{display:none!important;}
27930    .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%;}
27931    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
27932    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
27933    .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;}
27934    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
27935    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
27936    .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;}
27937    .scope-option:hover{background:var(--line);}
27938    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
27939    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
27940    .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;}
27941    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
27942    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
27943    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
27944    .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;}
27945  </style>
27946</head>
27947<body>
27948  <div class="background-watermarks" aria-hidden="true">
27949    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27950    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27951    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27952    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27953    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27954    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27955  </div>
27956  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27957  <div class="top-nav">
27958    <div class="top-nav-inner">
27959      <a class="brand" href="/">
27960        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27961        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
27962      </a>
27963      <div class="nav-right">
27964        <a class="nav-pill" href="/">Home</a>
27965        <div class="nav-dropdown">
27966          <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>
27967          <div class="nav-dropdown-menu">
27968            <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>
27969          </div>
27970        </div>
27971        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
27972        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27973        <div class="nav-dropdown">
27974          <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>
27975          <div class="nav-dropdown-menu">
27976            <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>
27977          </div>
27978        </div>
27979        <div class="server-status-wrap" id="server-status-wrap">
27980          <div class="nav-pill server-online-pill" id="server-status-pill">
27981            <span class="status-dot" id="status-dot"></span>
27982            <span id="server-status-label">Server</span>
27983            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27984          </div>
27985          <div class="server-status-tip">
27986            OxideSLOC is running — accessible on your network.
27987            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27988          </div>
27989        </div>
27990        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27991          <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>
27992        </button>
27993        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27994          <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>
27995          <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>
27996        </button>
27997      </div>
27998    </div>
27999  </div>
28000
28001  <div class="page">
28002    <div class="watched-bar">
28003      <div class="watched-bar-left">
28004        <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>
28005        <span class="watched-label">Watched Folders</span>
28006        <div class="watched-chips">
28007          {% if server_mode %}
28008          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28009          {% else %}
28010          {% for dir in watched_dirs %}
28011          <span class="watched-chip">
28012            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28013            <form method="POST" action="/watched-dirs/remove" style="display:contents">
28014              <input type="hidden" name="folder_path" value="{{ dir }}">
28015              <input type="hidden" name="redirect_to" value="/compare-scans">
28016              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
28017            </form>
28018          </span>
28019          {% endfor %}
28020          {% if watched_dirs.is_empty() %}
28021          <span class="watched-none">No folders watched — click Choose to add one</span>
28022          {% endif %}
28023          {% endif %}
28024        </div>
28025      </div>
28026      {% if !server_mode %}
28027      <div class="watched-bar-right">
28028        <button type="button" class="btn" id="add-watched-btn">
28029          <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>
28030          Choose
28031        </button>
28032        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28033          <input type="hidden" name="redirect_to" value="/compare-scans">
28034          <button type="submit" class="btn">&#8635; Refresh</button>
28035        </form>
28036      </div>
28037      {% endif %}
28038    </div>
28039    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28040      <div class="scan-overlay-card">
28041        <div class="scan-spinner"></div>
28042        <div class="scan-overlay-text">Scanning folder…</div>
28043        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28044      </div>
28045    </div>
28046    <style>
28047    .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);}
28048    .scan-overlay.active{display:flex;}
28049    .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;}
28050    .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;}
28051    @keyframes scanSpin{to{transform:rotate(360deg);}}
28052    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28053    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28054    </style>
28055    {% if total_scans > 0 %}
28056    <div class="summary-strip">
28057      <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>
28058      <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>
28059      <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>
28060      <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>
28061    </div>
28062    {% endif %}
28063    <section class="panel">
28064      <div class="panel-header">
28065        <div>
28066          <h1>Compare Scans</h1>
28067          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28068        </div>
28069        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28070          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28071            <button class="btn primary" id="compare-btn" disabled>
28072              <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>
28073              Compare <span class="sel-count" id="sel-count">0</span> Selected
28074            </button>
28075          </div>
28076        </div>
28077      </div>
28078
28079      {% if entries.is_empty() %}
28080      <div class="empty-state">
28081        <strong>No scans yet</strong>
28082        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.
28083      </div>
28084      {% else %}
28085      <div class="filter-row">
28086        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
28087        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28088        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
28089      </div>
28090      <div class="scope-panel hidden" id="scope-panel">
28091        <div class="scope-panel-label">
28092          <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>
28093          Compare scope — choose what to include
28094        </div>
28095        <div class="scope-options" id="scope-options"></div>
28096      </div>
28097      {% if total_scans > 0 %}
28098      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28099        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28100          <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>
28101          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28102        </div>
28103      </div>
28104      {% endif %}
28105      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28106        <span class="compare-all-label">
28107          <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>
28108          Quick Compare All
28109        </span>
28110      </div>
28111      <div class="table-wrap">
28112        <table id="compare-table">
28113          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28114          <thead>
28115            <tr id="compare-thead">
28116              <th><div class="col-resize-handle"></div></th>
28117              <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>
28118              <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>
28119              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28120              <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>
28121              <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>
28122              <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>
28123              <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>
28124              <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>
28125              <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>
28126              <th>Submodules<div class="col-resize-handle"></div></th>
28127            </tr>
28128          </thead>
28129          <tbody id="compare-tbody">
28130            {% for entry in entries %}
28131            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28132                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28133                data-project="{{ entry.project_label }}"
28134                data-files="{{ entry.files_analyzed }}"
28135                data-code="{{ entry.code_lines }}"
28136                data-comments="{{ entry.comment_lines }}"
28137                data-blank="{{ entry.blank_lines }}"
28138                data-branch="{{ entry.git_branch }}"
28139                data-commit="{{ entry.git_commit }}"
28140                data-submodules="{{ entry.submodule_names_csv }}">
28141              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28142              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28143              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28144              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28145              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28146              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28147              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28148              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28149              <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>
28150              <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>
28151              <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>
28152            </tr>
28153            {% endfor %}
28154          </tbody>
28155        </table>
28156      </div>
28157      <div class="pagination">
28158        <span class="pagination-info" id="pagination-info"></span>
28159        <div class="pagination-btns" id="pagination-btns"></div>
28160        <div class="flex-row">
28161          <span class="per-page-label">Show</span>
28162          <select class="per-page" id="per-page-sel">
28163            <option value="10">10 per page</option>
28164            <option value="25" selected>25 per page</option>
28165            <option value="50">50 per page</option>
28166            <option value="100">100 per page</option>
28167          </select>
28168          <span class="per-page-label" id="page-range-label"></span>
28169        </div>
28170      </div>
28171      {% endif %}
28172    </section>
28173  </div>
28174
28175  <footer class="site-footer">
28176    local code analysis - metrics, history and reports
28177    &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>
28178    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28179    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28180    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28181    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28182  </footer>
28183
28184  <script nonce="{{ csp_nonce }}">
28185    (function () {
28186      // ── Theme ──────────────────────────────────────────────────────────────
28187      var storageKey = 'oxide-sloc-theme';
28188      var body = document.body;
28189      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28190      var toggle = document.getElementById('theme-toggle');
28191      if (toggle) toggle.addEventListener('click', function () {
28192        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28193        body.classList.toggle('dark-theme', next === 'dark');
28194        try { localStorage.setItem(storageKey, next); } catch(e) {}
28195      });
28196
28197      // ── State ─────────────────────────────────────────────────────────────
28198      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28199      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28200      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28201      window._allCompareRows = allRows;
28202
28203      // ── Stat chips ────────────────────────────────────────────────────────
28204      (function() {
28205        var projects = {}, latestTs = '', latestRow = null;
28206        allRows.forEach(function(r) {
28207          var p = r.dataset.project || ''; if (p) projects[p] = true;
28208          var ts = r.dataset.timestamp || '';
28209          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28210        });
28211        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();}
28212        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>':'');}
28213        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28214        if (latestRow) {
28215          setChipVal('agg-code', latestRow.dataset.code);
28216          setChipVal('agg-files', latestRow.dataset.files);
28217        }
28218        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(); });
28219      })();
28220
28221      // ── Branch filter population ──────────────────────────────────────────
28222      (function() {
28223        var branches = {};
28224        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28225        var sel = document.getElementById('branch-filter');
28226        if (sel) Object.keys(branches).sort().forEach(function(b) {
28227          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28228        });
28229      })();
28230
28231      // ── Filter ────────────────────────────────────────────────────────────
28232      function getFilteredRows() {
28233        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28234        var branch = ((document.getElementById('branch-filter') || {}).value || '');
28235        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28236          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28237          if (branch && (r.dataset.branch || '') !== branch) return false;
28238          return true;
28239        });
28240      }
28241
28242      // ── Pagination ────────────────────────────────────────────────────────
28243      function renderPage() {
28244        var filtered = getFilteredRows();
28245        var total = filtered.length;
28246        var totalPages = Math.max(1, Math.ceil(total / perPage));
28247        currentPage = Math.min(currentPage, totalPages);
28248        var start = (currentPage - 1) * perPage;
28249        var end = Math.min(start + perPage, total);
28250        var shown = {};
28251        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28252        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28253          r.style.display = shown[r.dataset.run] ? '' : 'none';
28254        });
28255        var rl = document.getElementById('page-range-label');
28256        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28257        var info = document.getElementById('pagination-info');
28258        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28259        var btns = document.getElementById('pagination-btns');
28260        if (!btns) return;
28261        btns.innerHTML = '';
28262        function makeBtn(lbl, pg, active, disabled) {
28263          var b = document.createElement('button');
28264          b.className = 'pg-btn' + (active ? ' active' : '');
28265          b.textContent = lbl; b.disabled = disabled;
28266          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28267          return b;
28268        }
28269        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28270        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28271        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28272        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28273      }
28274
28275      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28276      window.applyFilters = function() { currentPage = 1; renderPage(); };
28277
28278      // ── Sorting ───────────────────────────────────────────────────────────
28279      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28280      function doSort(col, type, order) {
28281        var tbody = document.getElementById('compare-tbody');
28282        if (!tbody) return;
28283        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28284        rows.sort(function(a, b) {
28285          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28286          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28287          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28288          return va < vb ? 1 : va > vb ? -1 : 0;
28289        });
28290        rows.forEach(function(r) { tbody.appendChild(r); });
28291        currentPage = 1; renderPage();
28292      }
28293      sortHeaders.forEach(function(th) {
28294        th.addEventListener('click', function(e) {
28295          if (e.target.classList.contains('col-resize-handle')) return;
28296          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28297          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28298          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28299          th.classList.add('sort-' + sortOrder);
28300          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28301          doSort(col, type, sortOrder);
28302        });
28303      });
28304
28305      // Apply default sort (timestamp desc) on initial load
28306      (function() {
28307        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28308        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28309      })();
28310
28311      // ── Column resize ─────────────────────────────────────────────────────
28312      (function() {
28313        var table = document.getElementById('compare-table');
28314        if (!table) return;
28315        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28316        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28317        ths.forEach(function(th, i) {
28318          var handle = th.querySelector('.col-resize-handle');
28319          if (!handle || !cols[i]) return;
28320          var startX, startW;
28321          handle.addEventListener('mousedown', function(e) {
28322            e.stopPropagation(); e.preventDefault();
28323            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28324            handle.classList.add('dragging');
28325            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28326            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28327            document.addEventListener('mousemove', onMove);
28328            document.addEventListener('mouseup', onUp);
28329          });
28330        });
28331      })();
28332
28333      // ── Full-commit hover tooltip ─────────────────────────────────────────
28334      // The commit chips live inside an overflow:auto table wrapper, which would
28335      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28336      // (escaping the scroll container) and follow the cursor. Event delegation
28337      // keeps it working after pagination/sorting re-renders the rows.
28338      (function() {
28339        var tip = document.createElement('div');
28340        tip.className = 'commit-tip';
28341        tip.setAttribute('role', 'tooltip');
28342        document.body.appendChild(tip);
28343        var shown = false;
28344        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28345        function place(e) {
28346          var pad = 14, r = tip.getBoundingClientRect();
28347          var x = e.clientX + pad, y = e.clientY + pad;
28348          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28349          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28350          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28351        }
28352        function hide() { tip.style.display = 'none'; shown = false; }
28353        document.addEventListener('mouseover', function(e) {
28354          var chip = chipFrom(e.target);
28355          if (!chip) return;
28356          var full = chip.getAttribute('data-full-commit');
28357          if (!full) return;
28358          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28359        });
28360        document.addEventListener('mousemove', function(e) {
28361          if (!shown) return;
28362          if (chipFrom(e.target)) place(e); else hide();
28363        });
28364        document.addEventListener('mouseout', function(e) {
28365          if (chipFrom(e.target)) hide();
28366        });
28367      })();
28368
28369      // ── Reset view ────────────────────────────────────────────────────────
28370      window.resetView = function() {
28371        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28372        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28373        sortCol = null; sortOrder = 'asc';
28374        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28375        var tbody = document.getElementById('compare-tbody');
28376        if (tbody) {
28377          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28378          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28379          rows.forEach(function(r) { tbody.appendChild(r); });
28380        }
28381        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28382        var table = document.getElementById('compare-table');
28383        currentPage = 1; renderPage();
28384        currentPage = 1; renderPage();
28385      };
28386
28387      renderPage();
28388      buildCompareAllBar();
28389
28390      // ── Row selection state ───────────────────────────────────────────────
28391      var selected = [];
28392      var lockedProject = null; // project label of first selected scan
28393
28394      function updateCompareBtn() {
28395        var btn = document.getElementById('compare-btn');
28396        var cnt = document.getElementById('sel-count');
28397        if (!btn) return;
28398        btn.disabled = selected.length < 2;
28399        if (cnt) cnt.textContent = selected.length;
28400      }
28401
28402      function applyProjectLock() {
28403        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28404        allRows.forEach(function(r) {
28405          if (lockedProject === null) {
28406            r.classList.remove('row-locked');
28407          } else {
28408            var proj = r.dataset.project || '';
28409            if (proj !== lockedProject) {
28410              r.classList.add('row-locked');
28411            } else {
28412              r.classList.remove('row-locked');
28413            }
28414          }
28415        });
28416      }
28417
28418      function toggleRow(row) {
28419        if (row.classList.contains('row-locked')) return;
28420        var vid = row.dataset.vid || row.dataset.run;
28421        var idx = selected.indexOf(vid);
28422        if (idx >= 0) {
28423          selected.splice(idx, 1);
28424          row.classList.remove('selected');
28425          var b = document.getElementById('badge-' + vid);
28426          if (b) b.textContent = '';
28427          // Release project lock if nothing selected
28428          if (selected.length === 0) lockedProject = null;
28429        } else {
28430          // Set project lock on first selection
28431          if (selected.length === 0) lockedProject = row.dataset.project || null;
28432          selected.push(vid);
28433          row.classList.add('selected');
28434        }
28435        selected.forEach(function(v, i) {
28436          var b = document.getElementById('badge-' + v);
28437          if (b) b.textContent = i + 1;
28438        });
28439        applyProjectLock();
28440        updateCompareBtn();
28441        buildScopePanel();
28442      }
28443
28444      // ── Compare-All bar ───────────────────────────────────────────────────
28445      function buildCompareAllBar() {
28446        var bar = document.getElementById('compare-all-bar');
28447        if (!bar) return;
28448        // Group all rows by project label.
28449        var groups = {};
28450        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28451        // Use all rows from the source data (not just visible).
28452        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28453        // We need ALL rows across all pages, not just the rendered ones.
28454        // Use the underlying allRows array that the pagination JS also uses.
28455        var sourceRows = window._allCompareRows || allRowsAll;
28456        sourceRows.forEach(function(r) {
28457          var proj = r.dataset.project || '';
28458          var vid = r.dataset.vid || r.dataset.run || '';
28459          if (!proj || !vid) return;
28460          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28461          groups[proj].ids.push(vid);
28462          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28463        });
28464        // Build buttons for each project with >= 2 scans.
28465        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28466        if (!keys.length) { bar.style.display = 'none'; return; }
28467        bar.style.display = 'flex';
28468        // Remove old buttons (keep label).
28469        var oldBtns = bar.querySelectorAll('.compare-all-btn');
28470        oldBtns.forEach(function(b) { b.remove(); });
28471        keys.sort();
28472        keys.forEach(function(proj) {
28473          var g = groups[proj];
28474          var btn = document.createElement('button');
28475          btn.className = 'compare-all-btn';
28476          btn.type = 'button';
28477          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28478          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28479          btn.addEventListener('click', function() {
28480            // Sort ids by timestamp (ascending).
28481            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28482            pairs.sort(function(a, b) { return a.ts - b.ts; });
28483            var sorted = pairs.map(function(p) { return p.id; });
28484            if (sorted.length === 2) {
28485              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28486            } else {
28487              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28488            }
28489          });
28490          bar.appendChild(btn);
28491        });
28492      }
28493
28494      // ── Scope panel ───────────────────────────────────────────────────────
28495      var selectedScope = 'all';
28496
28497      function buildScopePanel() {
28498        var panel = document.getElementById('scope-panel');
28499        var opts = document.getElementById('scope-options');
28500        if (!panel || !opts) return;
28501        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28502
28503        // Collect union of submodules from all selected rows.
28504        var allSubs = {};
28505        selected.forEach(function(vid) {
28506          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28507          if (!row) return;
28508          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28509        });
28510        var subList = Object.keys(allSubs).sort();
28511        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28512
28513        panel.classList.remove('hidden');
28514        opts.innerHTML = '';
28515
28516        function makeOption(value, label, title) {
28517          var div = document.createElement('div');
28518          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28519          div.dataset.scopeValue = value;
28520          if (title) div.title = title;
28521          var radio = document.createElement('span');
28522          radio.className = 'scope-option-radio';
28523          var lbl = document.createElement('span');
28524          lbl.textContent = label;
28525          div.appendChild(radio);
28526          div.appendChild(lbl);
28527          div.addEventListener('click', function() {
28528            selectedScope = value;
28529            opts.querySelectorAll('.scope-option').forEach(function(o) {
28530              o.classList.toggle('selected', o.dataset.scopeValue === value);
28531            });
28532          });
28533          return div;
28534        }
28535
28536        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28537        var sep = document.createElement('span');
28538        sep.className = 'scope-option-sep';
28539        opts.appendChild(sep);
28540        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28541        subList.forEach(function(s) {
28542          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28543        });
28544      }
28545
28546      function doCompare() {
28547        if (selected.length < 2) return;
28548        if (selected.length === 2) {
28549          // Two-scan delta (existing flow with scope support).
28550          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28551          if (selectedScope === 'super') url += '&scope=super';
28552          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28553          window.location.href = url;
28554        } else {
28555          // Multi-scan timeline (N >= 3) — pass scope params too.
28556          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28557          if (selectedScope === 'super') url += '&scope=super';
28558          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28559          window.location.href = url;
28560        }
28561      }
28562
28563      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28564      var cbtn = document.getElementById('compare-btn');
28565      if (cbtn) cbtn.addEventListener('click', doCompare);
28566      var pfEl = document.getElementById('project-filter');
28567      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28568      var bfEl = document.getElementById('branch-filter');
28569      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28570      var rvBtn = document.getElementById('reset-view-btn');
28571      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28572      var ppSel = document.getElementById('per-page-sel');
28573      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28574
28575      var cmpTbody = document.getElementById('compare-tbody');
28576      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28577        var row = e.target.closest('.compare-row');
28578        if (row) toggleRow(row);
28579      });
28580
28581      (function randomizeWatermarks() {
28582        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28583        if (!wms.length) return;
28584        var placed = [];
28585        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;}
28586        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];}
28587        var half=Math.floor(wms.length/2);
28588        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;});
28589      })();
28590
28591      (function spawnCodeParticles() {
28592        var container = document.getElementById('code-particles');
28593        if (!container) return;
28594        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'];
28595        for (var i = 0; i < 38; i++) {
28596          (function(idx) {
28597            var el = document.createElement('span');
28598            el.className = 'code-particle';
28599            el.textContent = snippets[idx % snippets.length];
28600            var left = Math.random() * 94 + 2;
28601            var top = Math.random() * 88 + 6;
28602            var dur = (Math.random() * 10 + 9).toFixed(1);
28603            var delay = (Math.random() * 18).toFixed(1);
28604            var rot = (Math.random() * 26 - 13).toFixed(1);
28605            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28606            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';
28607            container.appendChild(el);
28608          })(i);
28609        }
28610      })();
28611
28612      // ── Watched folder picker ─────────────────────────────────────────────
28613      (function(){
28614        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');};
28615        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);
28616      })();
28617      (function() {
28618        var btn = document.getElementById('add-watched-btn');
28619        if (!btn) return;
28620        btn.addEventListener('click', function() {
28621          fetch('/pick-directory?kind=reports')
28622            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28623            .then(function(data) {
28624              if (!data.cancelled && data.selected_path) {
28625                var form = document.createElement('form');
28626                form.method = 'POST';
28627                form.action = '/watched-dirs/add';
28628                var ri = document.createElement('input');
28629                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28630                var fi = document.createElement('input');
28631                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28632                form.appendChild(ri); form.appendChild(fi);
28633                document.body.appendChild(form);
28634                if (window.__scanOverlay) window.__scanOverlay();
28635                form.submit();
28636              }
28637            })
28638            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28639        });
28640      })();
28641
28642      // ── Submodule chip truncation ─────────────────────────────────────────
28643      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28644        var chips = cell.querySelectorAll('.submod-chip');
28645        var MAX = 4;
28646        if (chips.length <= MAX) return;
28647        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28648        var badge = document.createElement('span');
28649        badge.className = 'submod-overflow-badge';
28650        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28651        badge.textContent = '+' + (chips.length - MAX) + ' more';
28652        cell.appendChild(badge);
28653        cell.style.maxHeight = 'none';
28654      });
28655    })();
28656  </script>
28657  <script nonce="{{ csp_nonce }}">
28658  (function(){
28659    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'}];
28660    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);});}
28661    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28662    function init(){
28663      var btn=document.getElementById('settings-btn');if(!btn)return;
28664      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28665      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>';
28666      document.body.appendChild(m);
28667      var g=document.getElementById('scheme-grid');
28668      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);});
28669      var cl=document.getElementById('settings-close');
28670      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);});})();
28671      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');});
28672      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28673      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28674    }
28675    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28676  }());
28677  </script>
28678  <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]';
28679  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;}
28680  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>
28681</body>
28682</html>
28683"##,
28684    ext = "html"
28685)]
28686struct CompareSelectTemplate {
28687    version: &'static str,
28688    entries: Vec<HistoryEntryRow>,
28689    total_scans: usize,
28690    watched_dirs: Vec<String>,
28691    csp_nonce: String,
28692    server_mode: bool,
28693}
28694
28695// ── CompareTemplate ────────────────────────────────────────────────────────────
28696
28697#[derive(Template)]
28698#[template(
28699    source = r##"
28700<!doctype html>
28701<html lang="en">
28702<head>
28703  <meta charset="utf-8">
28704  <meta name="viewport" content="width=device-width, initial-scale=1">
28705  <title>OxideSLOC | Scan Delta</title>
28706  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28707  <style nonce="{{ csp_nonce }}">
28708    :root {
28709      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
28710      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
28711      --nav:#283790; --nav-2:#013e6b;
28712      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
28713      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
28714      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
28715    }
28716    body.dark-theme {
28717      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
28718      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
28719    }
28720    *{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;}
28721    .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);}
28722    .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;}
28723    .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));}
28724    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28725    .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;}
28726    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
28727    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28728    @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; } }
28729    .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;}
28730    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
28731    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28732    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28733    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28734    .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;}
28735    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28736    .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);}
28737    .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;}
28738    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28739    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28740    .settings-modal-body{padding:14px 16px 16px;}
28741    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28742    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28743    .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;}
28744    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28745    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28746    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28747    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28748    .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;}
28749    .tz-select:focus{border-color:var(--oxide);}
28750    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28751    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28752    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28753    .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;}
28754    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
28755    .hero-body{display:block;}
28756    .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;}
28757    .btn-back:hover{background:var(--line);}
28758    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
28759    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
28760    .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;}
28761    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
28762    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;}
28763    .muted{color:var(--muted);font-size:14px;}
28764    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
28765    .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;}
28766    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
28767    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
28768    .vpill-arrow{font-size:20px;color:var(--muted);}
28769    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
28770    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
28771    .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;}
28772    .delta-card.delta-card-wide{padding:22px 24px;}
28773    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
28774    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
28775    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
28776    .delta-card-from{font-size:15px;color:var(--muted);}
28777    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
28778    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
28779    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
28780    .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%;}
28781    .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;}
28782    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
28783    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
28784    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
28785    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
28786    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
28787    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
28788    .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;}
28789    .meta-card-commit:hover{color:var(--oxide);}
28790    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
28791    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
28792    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
28793    .meta-value{color:var(--text);font-size:13px;}
28794    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
28795    .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;}
28796    .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);}
28797    .delta-card:hover .dc-tip{display:block;}
28798    .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;}
28799    .export-btn:hover{background:var(--line);}
28800    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
28801    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
28802    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
28803    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
28804    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
28805    .delta-card-change.zero{color:var(--muted);background:transparent;}
28806    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
28807    .delta-card-pct.pos{color:var(--pos);}
28808    .delta-card-pct.neg{color:var(--neg);}
28809    .delta-card-pct.zero{color:var(--muted);}
28810    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
28811    .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;}
28812    .insight-card.insight-flag{border-color:var(--oxide);}
28813    .insight-card:hover .dc-tip{display:block;}
28814    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
28815    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
28816    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
28817    .insight-label.flag{color:var(--oxide);}
28818    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
28819    .insight-val.pos{color:var(--pos);}
28820    .insight-val.neg{color:var(--neg);}
28821    .insight-val.high{color:#c0392a;}
28822    .insight-val.med{color:#926000;}
28823    .insight-val.low{color:var(--pos);}
28824    body.dark-theme .insight-val.high{color:#ff6b6b;}
28825    body.dark-theme .insight-val.med{color:#f0c060;}
28826    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
28827    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
28828    .fc-row{display:flex;align-items:center;gap:8px;}
28829    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
28830    .fc-label{color:var(--muted);}
28831    .fc-modified .fc-count{color:#926000;}
28832    .fc-added .fc-count{color:var(--pos);}
28833    .fc-removed .fc-count{color:var(--neg);}
28834    .fc-unchanged .fc-count{color:var(--muted);}
28835    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
28836    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
28837    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
28838    .chip.modified{background:#fff2d8;color:#926000;}
28839    .chip.added{background:#e8f5ed;color:#1a8f47;}
28840    .chip.removed{background:#fdeaea;color:#b33b3b;}
28841    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
28842    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
28843    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
28844    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
28845    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
28846    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
28847    .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;}
28848    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
28849    .tab-btn:hover:not(.active){background:var(--line);}
28850    .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;}
28851    .btn-reset:hover{background:var(--line);}
28852    .table-wrap{width:100%;overflow-x:auto;}
28853    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
28854    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);}
28855    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28856    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28857    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28858    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28859    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28860    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
28861    tr:last-child td{border-bottom:none;}
28862    tr:hover td{background:var(--surface-2);}
28863    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
28864    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
28865    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
28866    /* Fixed layout: column widths come from the colgroup, not from scanning every
28867       row. With auto layout a large file matrix forces the browser to re-measure
28868       all cells on each reflow, which freezes the page during sort/resize. */
28869    #delta-table{table-layout:fixed;}
28870    #delta-table col:nth-child(1){width:32%;}
28871    #delta-table col:nth-child(2){width:11%;}
28872    #delta-table col:nth-child(3){width:11%;}
28873    #delta-table col:nth-child(4){width:16%;}
28874    #delta-table col:nth-child(5){width:10%;}
28875    #delta-table col:nth-child(6){width:10%;}
28876    #delta-table col:nth-child(7){width:10%;}
28877    tr.row-added td{background:rgba(26,143,71,0.04);}
28878    tr.row-removed td{background:rgba(179,59,59,0.06);}
28879    tr.row-modified td{background:rgba(146,96,0,0.04);}
28880    tr.row-unchanged td{color:var(--muted);}
28881    tr.row-unchanged .status-badge{opacity:.65;}
28882    .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;}
28883    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
28884    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
28885    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
28886    .status-badge.modified{background:#fff2d8;color:#926000;}
28887    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
28888    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
28889    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
28890    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
28891    .delta-val{font-weight:700;}
28892    .delta-val.pos{color:var(--pos);}
28893    .delta-val.neg{color:var(--neg);}
28894    .delta-val.zero{color:var(--muted);}
28895    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
28896    .from-to strong{color:var(--text);font-weight:700;}
28897    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
28898    .from-to .ft-absent{color:var(--muted);font-weight:600;}
28899    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28900    .site-footer a{color:var(--muted);}
28901    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;}
28902    body.pdf-mode{background:#fff!important;}
28903    body.pdf-mode .page{padding:4px 6px 4px!important;}
28904    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
28905    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
28906    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28907    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28908    .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;}
28909    .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;}
28910    .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;}
28911    @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));}}
28912    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
28913    .path-link:hover{color:var(--oxide-2);}
28914    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
28915    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
28916    a.vpill-id:hover{color:var(--oxide);}
28917    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
28918    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28919    .pagination-info{font-size:13px;color:var(--muted);}
28920    .pagination-btns{display:flex;gap:6px;}
28921    .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;}
28922    .pg-btn:hover:not(:disabled){background:var(--line);}
28923    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28924    .pg-btn:disabled{opacity:.35;cursor:default;}
28925    .per-page-label{font-size:13px;color:var(--muted);}
28926    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;}
28927    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28928    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
28929    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
28930    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
28931    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
28932    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
28933    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
28934    .tab-btn.tab-unchanged{color:var(--muted);}
28935    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
28936    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
28937    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
28938    .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;}
28939    .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;}
28940    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
28941    .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;}
28942    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28943    .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;}
28944    .submod-scope-btn:hover{background:var(--line);}
28945    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28946    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
28947    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
28948    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
28949    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
28950    body.dark-theme .ic-card{background:var(--surface-2);}
28951    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
28952    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
28953    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
28954    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
28955    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
28956    .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);}
28957    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
28958    .ic-card-h2-row .ic-card-h2{margin:0;}
28959    .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;}
28960    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
28961    .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;}
28962    .ic-svg-modal-ov.open{display:flex;}
28963    .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);}
28964    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
28965    .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);}
28966    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
28967    .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;}
28968    .ic-svg-modal-close:hover{background:var(--line);}
28969    .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;}
28970    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28971    .chart-metric-btn:hover:not(.active){background:var(--line);}
28972    .chart-wrap{width:100%;overflow-x:auto;}
28973    #cmp-tl-svg{display:block;width:100%;}
28974    .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);}
28975    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28976    #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;}
28977  </style>
28978</head>
28979<body>
28980  {{ loading_overlay|safe }}
28981  <div class="background-watermarks" aria-hidden="true">
28982    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28983    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28984    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28985    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28986    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28987    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28988  </div>
28989  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28990  <div class="top-nav">
28991    <div class="top-nav-inner">
28992      <a class="brand" href="/">
28993        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28994        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
28995      </a>
28996      <div class="nav-right">
28997        <a class="nav-pill" href="/">Home</a>
28998        <div class="nav-dropdown">
28999          <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>
29000          <div class="nav-dropdown-menu">
29001            <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>
29002          </div>
29003        </div>
29004        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29005        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29006        <div class="nav-dropdown">
29007          <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>
29008          <div class="nav-dropdown-menu">
29009            <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>
29010          </div>
29011        </div>
29012        <div class="server-status-wrap" id="server-status-wrap">
29013          <div class="nav-pill server-online-pill" id="server-status-pill">
29014            <span class="status-dot" id="status-dot"></span>
29015            <span id="server-status-label">Server</span>
29016            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29017          </div>
29018          <div class="server-status-tip">
29019            OxideSLOC is running — accessible on your network.
29020            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29021          </div>
29022        </div>
29023        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29024          <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>
29025        </button>
29026        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29027          <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>
29028          <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>
29029        </button>
29030      </div>
29031    </div>
29032  </div>
29033
29034  <div class="page">
29035    <section class="hero">
29036      <div class="hero-header">
29037        <div>
29038          <h1 class="delta-title">Scan Delta</h1>
29039          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29040          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29041            {% if let Some(sub) = active_submodule %}
29042            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29043            {% else if super_scope_active %}
29044            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29045            {% else %}
29046            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29047            {% endif %}
29048            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29049          </div>
29050        </div>
29051        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29052          <a class="btn-back" href="/compare-scans">
29053            <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>
29054            Compare Scans
29055          </a>
29056          <div class="export-group" style="margin-top:12px;">
29057            <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>
29058            <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>
29059          </div>
29060        </div>
29061      </div>
29062      {% if has_any_submodule_data %}
29063      <div class="submod-scope-bar">
29064        <span class="submod-scope-label">
29065          <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>
29066          Scope:
29067        </span>
29068        <div class="submod-scope-divider"></div>
29069        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29070           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
29071           title="All files — super-repo and all submodules combined">Full scan</a>
29072        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29073           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
29074           title="Only files that are not part of any submodule">Super-repo only</a>
29075        {% for sub in submodule_options %}
29076        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29077           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
29078           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29079        {% endfor %}
29080      </div>
29081      {% endif %}
29082      <div class="hero-body">
29083      <div class="meta-strip">
29084        <div class="delta-card delta-card-meta">
29085          <div class="meta-card-header">
29086            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29087            <div class="meta-card-project-col">
29088              <div class="meta-card-project">{{ project_name }}</div>
29089              {% if has_any_submodule_data %}
29090              {% if let Some(sub) = active_submodule %}
29091              <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>
29092              {% else if super_scope_active %}
29093              <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>
29094              {% else %}
29095              <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>
29096              {% endif %}
29097              {% endif %}
29098            </div>
29099          </div>
29100          {% if !baseline_git_commit.is_empty() %}
29101          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29102          {% else %}
29103          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29104          {% endif %}
29105          <div class="meta-card-rows">
29106            <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>
29107            <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>
29108            <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>
29109            <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>
29110            {% if let Some(tags) = baseline_git_tags %}
29111            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29112            {% endif %}
29113          </div>
29114        </div>
29115        <div class="delta-card delta-card-meta">
29116          <div class="meta-card-header">
29117            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29118            <div class="meta-card-project-col">
29119              <div class="meta-card-project">{{ project_name }}</div>
29120              {% if has_any_submodule_data %}
29121              {% if let Some(sub) = active_submodule %}
29122              <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>
29123              {% else if super_scope_active %}
29124              <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>
29125              {% else %}
29126              <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>
29127              {% endif %}
29128              {% endif %}
29129            </div>
29130          </div>
29131          {% if !current_git_commit.is_empty() %}
29132          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29133          {% else %}
29134          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29135          {% endif %}
29136          <div class="meta-card-rows">
29137            <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>
29138            <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>
29139            <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>
29140            <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>
29141            {% if let Some(tags) = current_git_tags %}
29142            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29143            {% endif %}
29144          </div>
29145        </div>
29146      </div>
29147      <div class="delta-strip">
29148        <div class="delta-card">
29149          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29150          <div class="delta-card-label">Code lines</div>
29151          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29152          <div class="delta-card-to">{{ current_code_fmt }}</div>
29153          {% 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>
29154          {% 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>
29155          {% else %}<div class="delta-card-pct zero">±0%</div>
29156          {% endif %}
29157        </div>
29158        <div class="delta-card">
29159          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29160          <div class="delta-card-label">Files analyzed</div>
29161          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29162          <div class="delta-card-to">{{ current_files_fmt }}</div>
29163          {% 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>
29164          {% 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>
29165          {% else %}<div class="delta-card-pct zero">±0%</div>
29166          {% endif %}
29167        </div>
29168        <div class="delta-card">
29169          <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>
29170          <div class="delta-card-label">Comment lines</div>
29171          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29172          <div class="delta-card-to">{{ current_comments_fmt }}</div>
29173          {% 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>
29174          {% 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>
29175          {% else %}<div class="delta-card-pct zero">±0%</div>
29176          {% endif %}
29177        </div>
29178        {{ coverage_delta_card|safe }}
29179        <div class="delta-card delta-card-wide">
29180          <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>
29181          <div class="delta-card-label">File changes</div>
29182          <div class="file-changes-grid">
29183            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29184            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29185            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29186            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29187          </div>
29188        </div>
29189      </div>
29190      <div class="insights-panel">
29191        <div class="insight-card">
29192          <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>
29193          <div class="insight-label">Lines Added</div>
29194          <div class="insight-val pos">+{{ code_lines_added }}</div>
29195          <div class="insight-sub">New or grown source lines</div>
29196        </div>
29197        <div class="insight-card">
29198          <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>
29199          <div class="insight-label">Lines Removed</div>
29200          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
29201          <div class="insight-sub">Deleted or shrunk source lines</div>
29202        </div>
29203        <div class="insight-card">
29204          <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>
29205          <div class="insight-label">Lines Modified</div>
29206          <div class="insight-val">{{ code_lines_modified }}</div>
29207          <div class="insight-sub">Code lines in modified files</div>
29208        </div>
29209        <div class="insight-card">
29210          <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>
29211          <div class="insight-label">Lines Unmodified</div>
29212          <div class="insight-val">{{ code_lines_unmodified }}</div>
29213          <div class="insight-sub">Code lines in unchanged files</div>
29214        </div>
29215        <div class="insight-card">
29216          <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>
29217          <div class="insight-label">Churn Rate</div>
29218          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29219          <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>
29220        </div>
29221        {% if scope_flag %}
29222        <div class="insight-card insight-flag">
29223          <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>
29224          <div class="insight-label flag">Scope Signal</div>
29225          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29226          <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>
29227        </div>
29228        {% endif %}
29229      </div>
29230      </div>
29231    </section>
29232
29233    <section class="panel" id="inline-charts-section">
29234      <div class="panel-title">Scan Delta Charts</div>
29235      <div class="ic-grid">
29236        <div class="ic-card" style="grid-column:span 2">
29237          <div class="ic-card-h2-row">
29238            <span class="ic-card-h2">Timeline</span>
29239            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29240              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29241              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29242              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29243              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29244              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29245            </div>
29246            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
29247          </div>
29248          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29249        </div>
29250        <div class="ic-card">
29251          <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>
29252          <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>
29253          <div id="ic-c1"></div>
29254        </div>
29255        <div class="ic-card" id="ic-lang-card">
29256          <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>
29257          <div id="ic-c3"></div>
29258        </div>
29259        <div class="ic-card">
29260          <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>
29261          <div id="ic-c2"></div>
29262        </div>
29263        <div class="ic-card">
29264          <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>
29265          <div id="ic-c4"></div>
29266        </div>
29267      </div>
29268      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29269        <div class="ic-svg-modal">
29270          <div class="ic-svg-modal-hdr">
29271            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29272            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
29273          </div>
29274          <div id="ic-svg-modal-body"></div>
29275        </div>
29276      </div>
29277    </section>
29278
29279    <section class="panel">
29280      <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>
29281      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29282        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29283          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29284          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29285          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29286          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29287          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29288        </div>
29289        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29290          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
29291          <div class="export-group">
29292            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
29293            <button type="button" class="export-btn" id="delta-csv-btn">
29294              <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>
29295              CSV
29296            </button>
29297            <button type="button" class="export-btn" id="delta-xls-btn">
29298              <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>
29299              Excel
29300            </button>
29301          </div>
29302        </div>
29303      </div>
29304
29305      <div class="table-wrap">
29306      <table id="delta-table">
29307        <colgroup>
29308          <col>
29309          <col>
29310          <col>
29311          <col>
29312          <col>
29313          <col>
29314          <col>
29315        </colgroup>
29316        <thead>
29317          <tr id="delta-thead">
29318            <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>
29319            <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>
29320            <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>
29321            <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>
29322            <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>
29323            <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>
29324            <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>
29325          </tr>
29326        </thead>
29327        <tbody id="delta-tbody">
29328          {% for row in file_rows %}
29329          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29330              data-path="{{ row.relative_path }}"
29331              data-language="{{ row.language }}"
29332              data-baseline-code="{{ row.baseline_code }}"
29333              data-current-code="{{ row.current_code }}"
29334              data-code-delta="{{ row.code_delta_str }}"
29335              data-comment-delta="{{ row.comment_delta_str }}"
29336              data-total-delta="{{ row.total_delta_str }}"
29337              data-orig-idx="">
29338            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29339            <td class="hide-sm">{{ row.language }}</td>
29340            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29341            <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>
29342            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29343            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29344            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29345          </tr>
29346          {% endfor %}
29347        </tbody>
29348      </table>
29349      </div>
29350      <div class="pagination">
29351        <span class="pagination-info" id="pg-range-label"></span>
29352        <div class="pagination-btns" id="pg-btns"></div>
29353        <div class="flex-row">
29354          <span class="per-page-label">Show</span>
29355          <select class="per-page" id="per-page-sel">
29356            <option value="10">10 per page</option>
29357            <option value="25" selected>25 per page</option>
29358            <option value="50">50 per page</option>
29359            <option value="100">100 per page</option>
29360          </select>
29361        </div>
29362      </div>
29363    </section>
29364  </div>
29365
29366  <div id="ic-tt"></div>
29367
29368  <footer class="site-footer">
29369    local code analysis - metrics, history and reports
29370    &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>
29371    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29372    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29373    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29374    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29375  </footer>
29376
29377  <script nonce="{{ csp_nonce }}">
29378    (function () {
29379      var storageKey = 'oxide-sloc-theme';
29380      var body = document.body;
29381      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29382      var toggle = document.getElementById('theme-toggle');
29383      if (toggle) toggle.addEventListener('click', function () {
29384        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29385        body.classList.toggle('dark-theme', next === 'dark');
29386        try { localStorage.setItem(storageKey, next); } catch(e) {}
29387      });
29388
29389      (function randomizeWatermarks() {
29390        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29391        if (!wms.length) return;
29392        var placed = [];
29393        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;}
29394        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];}
29395        var half=Math.floor(wms.length/2);
29396        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;});
29397      })();
29398
29399      (function spawnCodeParticles() {
29400        var container = document.getElementById('code-particles');
29401        if (!container) return;
29402        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'];
29403        for (var i = 0; i < 38; i++) {
29404          (function(idx) {
29405            var el = document.createElement('span');
29406            el.className = 'code-particle';
29407            el.textContent = snippets[idx % snippets.length];
29408            var left = Math.random() * 94 + 2;
29409            var top = Math.random() * 88 + 6;
29410            var dur = (Math.random() * 10 + 9).toFixed(1);
29411            var delay = (Math.random() * 18).toFixed(1);
29412            var rot = (Math.random() * 26 - 13).toFixed(1);
29413            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29414            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';
29415            container.appendChild(el);
29416          })(i);
29417        }
29418      })();
29419    })();
29420
29421    var activeStatusFilter = 'all';
29422    var deltaPerPage = 25, deltaCurrPage = 1;
29423
29424    function openFolder(path) {
29425      fetch('/open-path?path=' + encodeURIComponent(path))
29426        .then(function (r) { return r.json(); })
29427        .then(function (d) {
29428          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29429        })
29430        .catch(function () {});
29431    }
29432
29433    // \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
29434    // The server renders every row once; we lift them into a plain-data array and
29435    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29436    // filtering run on the array (no DOM churn) and each render rebuilds just one
29437    // page (~25 rows). This keeps every interaction O(page) instead of O(all
29438    // files): a 28k-row table previously re-touched every node on each click
29439    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29440    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29441
29442    function parseDeltaNum(str) {
29443      if (!str || str === '\u2014') return 0;
29444      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29445    }
29446
29447    function captureDelta() {
29448      var tbody = document.getElementById('delta-tbody');
29449      if (!tbody) return;
29450      var rows = tbody.querySelectorAll('.delta-row');
29451      for (var i = 0; i < rows.length; i++) {
29452        var r = rows[i];
29453        DELTA.push({
29454          h: r.innerHTML,
29455          cls: r.className,
29456          path: r.getAttribute('data-path') || '',
29457          lang: r.getAttribute('data-language') || '',
29458          status: r.getAttribute('data-status') || '',
29459          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29460          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29461          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29462          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29463          td: parseDeltaNum(r.getAttribute('data-total-delta')),
29464          bcs: r.getAttribute('data-baseline-code') || '',
29465          ccs: r.getAttribute('data-current-code') || '',
29466          cds: r.getAttribute('data-code-delta') || '',
29467          cmds: r.getAttribute('data-comment-delta') || '',
29468          tds: r.getAttribute('data-total-delta') || ''
29469        });
29470      }
29471      tbody.innerHTML = '';
29472    }
29473
29474    function applyDeltaQuery() {
29475      var v = (activeStatusFilter === 'all') ? DELTA.slice()
29476        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29477      if (sortCol) {
29478        var asc = sortOrder === 'asc';
29479        v.sort(function(a, b) {
29480          var va, vb;
29481          if (sortCol === 'path') { va = a.path; vb = b.path; }
29482          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29483          else if (sortCol === 'status') { va = a.status; vb = b.status; }
29484          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29485          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29486          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29487          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29488          else { return 0; }
29489          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29490          return va < vb ? 1 : va > vb ? -1 : 0;
29491        });
29492      }
29493      _deltaView = v;
29494      deltaCurrPage = 1;
29495      renderDeltaPage();
29496    }
29497
29498    function renderDeltaPage() {
29499      var total = _deltaView.length;
29500      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29501      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29502      if (deltaCurrPage < 1) deltaCurrPage = 1;
29503      var start = (deltaCurrPage - 1) * deltaPerPage;
29504      var end = Math.min(start + deltaPerPage, total);
29505      var tbody = document.getElementById('delta-tbody');
29506      if (tbody) {
29507        var html = '';
29508        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29509        tbody.innerHTML = html;
29510      }
29511      var rl = document.getElementById('pg-range-label');
29512      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29513      var btns = document.getElementById('pg-btns');
29514      if (!btns) return;
29515      btns.innerHTML = '';
29516      if (totalPages <= 1) return;
29517      function makeBtn(lbl, pg, active, disabled) {
29518        var b = document.createElement('button');
29519        b.className = 'pg-btn' + (active ? ' active' : '');
29520        b.textContent = lbl; b.disabled = disabled;
29521        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29522        return b;
29523      }
29524      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29525      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29526      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29527      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29528    }
29529
29530    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29531
29532    function filterRows(status, btn) {
29533      activeStatusFilter = status;
29534      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29535        b.classList.remove('active');
29536      });
29537      if (btn) btn.classList.add('active');
29538      applyDeltaQuery();
29539    }
29540
29541    // ── Sorting ──────────────────────────────────────────────────────────────
29542    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29543    sortHeaders.forEach(function(th) {
29544      th.addEventListener('click', function(e) {
29545        if (e.target.classList.contains('col-resize-handle')) return;
29546        var col = th.dataset.sortCol;
29547        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29548        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29549        th.classList.add('sort-' + sortOrder);
29550        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29551        applyDeltaQuery();
29552      });
29553    });
29554
29555    // ── Column resize ─────────────────────────────────────────────────────────
29556    (function() {
29557      var table = document.getElementById('delta-table');
29558      if (!table) return;
29559      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29560      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29561      ths.forEach(function(th, i) {
29562        var handle = th.querySelector('.col-resize-handle');
29563        if (!handle || !cols[i]) return;
29564        handle.addEventListener('mousedown', function(e) {
29565          e.stopPropagation(); e.preventDefault();
29566          // Lock every column to its current rendered px width and size the table
29567          // to the column total. With table-layout:fixed + width:100% the table is
29568          // pinned to the container, so widening one <col> only rebalances the rest
29569          // and the drag looks inert; pinning px widths lets the column actually
29570          // grow while the wrapper (overflow-x:auto) scrolls.
29571          var startTableW = 0;
29572          for (var k = 0; k < ths.length; k++) {
29573            if (!cols[k]) continue;
29574            var w = ths[k].getBoundingClientRect().width;
29575            cols[k].style.width = w + 'px';
29576            startTableW += w;
29577          }
29578          table.style.width = startTableW + 'px';
29579          var startX = e.clientX;
29580          var startW = ths[i].getBoundingClientRect().width;
29581          handle.classList.add('dragging');
29582          function onMove(ev) {
29583            var newW = Math.max(40, startW + ev.clientX - startX);
29584            cols[i].style.width = newW + 'px';
29585            table.style.width = (startTableW + (newW - startW)) + 'px';
29586          }
29587          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29588          document.addEventListener('mousemove', onMove);
29589          document.addEventListener('mouseup', onUp);
29590        });
29591      });
29592    })();
29593
29594    // ── Reset ─────────────────────────────────────────────────────────────────
29595    window.resetDeltaTable = function() {
29596      sortCol = null; sortOrder = 'asc';
29597      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29598      var table = document.getElementById('delta-table');
29599      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29600      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29601      activeStatusFilter = 'all';
29602      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29603      var allBtn = document.querySelector('.tab-btn');
29604      if (allBtn) allBtn.classList.add('active');
29605      applyDeltaQuery();
29606    };
29607
29608    // Compact number formatter (shared by the delta table; charts define their own locally)
29609    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();}
29610    function fmtFull(n){return Number(n).toLocaleString();}
29611
29612    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29613    function fmtFromTo() {
29614      var tbody = document.getElementById('delta-tbody');
29615      if (!tbody) return;
29616      tbody.querySelectorAll('.delta-row').forEach(function(row) {
29617        var status = row.dataset.status || '';
29618        var ft = row.querySelector('.from-to');
29619        if (!ft) return;
29620        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29621        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29622        var strongs = ft.querySelectorAll('strong');
29623        // Apply fmt() to non-absent strong values
29624        strongs.forEach(function(el) {
29625          var n = parseInt(el.textContent, 10);
29626          if (!isNaN(n)) el.textContent = fmtFull(n);
29627        });
29628        // Safety: force dash for genuinely absent sides
29629        if (status === 'added' && bv === 0) {
29630          var bs = ft.querySelector('strong:first-of-type');
29631          if (bs && bs.textContent === '0') {
29632            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29633          }
29634        }
29635        if (status === 'removed' && cv === 0) {
29636          var cs = ft.querySelector('strong:last-of-type');
29637          if (cs && cs.textContent === '0') {
29638            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29639          }
29640        }
29641      });
29642    }
29643    // Initialize: format the server-rendered rows, lift them into the data model
29644    // (which also clears the DOM), then render only the first page.
29645    fmtFromTo();
29646    captureDelta();
29647    applyDeltaQuery();
29648
29649    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29650    (function() {
29651      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29652        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29653      });
29654      var resetBtn = document.getElementById('delta-reset-btn');
29655      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29656      var csvBtn = document.getElementById('delta-csv-btn');
29657      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29658      var xlsBtn = document.getElementById('delta-xls-btn');
29659      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29660      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29661      function sdFetchUri(path) {
29662        return fetch(path).then(function(r){return r.blob();}).then(function(b){
29663          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
29664        }).catch(function(){return '';});
29665      }
29666      function sdInlineImgs(html, cb) {
29667        var paths=[], seen={};
29668        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
29669        if(!paths.length){cb(html);return;}
29670        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
29671          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
29672          .catch(function(){cb(html);});
29673      }
29674      function buildFullPageHtml(pdfMode) {
29675        if(pdfMode) document.body.classList.add('pdf-mode');
29676        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
29677        renderDeltaPage();
29678        var html = document.documentElement.outerHTML;
29679        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
29680        if(pdfMode) document.body.classList.remove('pdf-mode');
29681        return html;
29682      }
29683      var chartsBtn = document.getElementById('delta-charts-btn');
29684      if (chartsBtn) chartsBtn.addEventListener('click', function() {
29685        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29686        sdInlineImgs(buildFullPageHtml(false), function(html) {
29687          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29688          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29689          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29690          btn.disabled=false;btn.innerHTML=orig;
29691        });
29692      });
29693      var pageHtmlBtn = document.getElementById('page-export-html-btn');
29694      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
29695        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
29696        sdInlineImgs(buildFullPageHtml(false), function(html) {
29697          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
29698          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
29699          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
29700          btn.disabled=false;btn.innerHTML=orig;
29701        });
29702      });
29703      // PDF export — clean document-style report, not a web page screenshot
29704      function buildDeltaPdfHtml() {
29705        var sd=_sd, dr=getDeltaExportRows();
29706        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
29707        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+'%';}
29708        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
29709        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
29710        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
29711        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
29712        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
29713        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29714        function fmtN(n){return Number(n).toLocaleString();}
29715        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
29716        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>';}
29717        var lm={};
29718        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;});
29719        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
29720        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
29721        // The header/footer flow in normal document order (NOT position:fixed).
29722        // A fixed header repeats on every printed page in Chromium and overlaps
29723        // the content beneath it — silently swallowing the first few table rows of
29724        // pages 2+ and clipping the summary cards on page 1. Letting the header
29725        // flow once at the top and relying on the table's <thead> (which Chromium
29726        // repeats per page) keeps every row visible. `.body` keeps a small inset
29727        // so nothing bleeds to the sheet edge.
29728        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
29729          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29730          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29731          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
29732          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
29733          '.ph-brand em{color:#c45c10;font-style:normal;}'+
29734          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
29735          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
29736          '.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;}'+
29737          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
29738          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
29739          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
29740          '.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;}'+
29741          '.body{padding:12px 18px 0;}'+
29742          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
29743          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
29744          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
29745          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
29746          '.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;}'+
29747          '.meta>div{flex:1 1 0;}'+
29748          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
29749          '.sec{margin-bottom:10px;}'+
29750          '.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;}'+
29751          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
29752          '.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;}'+
29753          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
29754          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
29755          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
29756          '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;}'+
29757          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
29758          'tr:nth-child(even) td{background:#faf8f6;}'+
29759          '.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;}'+
29760          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
29761          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29762          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
29763          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
29764          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
29765          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
29766          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
29767          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
29768          '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
29769          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
29770          '.fcc-n{font-size:18px;font-weight:900;}'+
29771          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
29772        var fileRows=dchg.map(function(r){
29773          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
29774          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
29775            '<td style="'+ss+'">'+esc(st)+'</td>'+
29776            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
29777            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
29778            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
29779        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
29780        var more='';
29781        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('');
29782        var extraCards='';
29783        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>';}
29784        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>';}
29785        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
29786          '<div class="pdf-header">'+
29787          '<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>'+
29788          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
29789          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
29790          '</div>'+
29791          '<div class="body">'+
29792          '<div class="sec"><p class="sh">Summary Metrics</p>'+
29793          '<div class="msec">'+
29794          '<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>'+
29795          '<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>'+
29796          '<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>'+
29797          '<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>'+
29798          '<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>'+
29799          '<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>'+
29800          extraCards+'</div></div>'+
29801          '<div class="sec"><p class="sh">File Changes</p>'+
29802          '<div class="fcsec">'+
29803          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
29804          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
29805          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
29806          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
29807          '</div></div>'+
29808          (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>':'')+
29809          '<div class="sec">'+
29810          '<table><thead>'+
29811          '<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>'+
29812          '<tr><th>File</th><th>Language</th><th>Status</th>'+
29813          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
29814          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
29815          '</div>'+
29816          '<div class="rfoot">'+
29817          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
29818          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
29819          '</div>'+
29820          '</body></html>';
29821      }
29822      function doDeltaPdf(btn) {
29823        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
29824      }
29825      var pdfBtn = document.getElementById('delta-pdf-btn');
29826      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
29827      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
29828      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
29829      if (location.protocol === 'file:') {
29830        [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'; } });
29831        [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'; } });
29832      }
29833      var ppSel = document.getElementById('per-page-sel');
29834      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
29835      var pathLink = document.getElementById('project-path-link');
29836      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
29837    })();
29838
29839    // ── Export helpers ────────────────────────────────────────────────────────
29840    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
29841    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
29842    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);}
29843    function slocMakeXlsx(fname,sd,dr){
29844      var enc=new TextEncoder();
29845      // CRC-32 table
29846      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;}
29847      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;}
29848      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
29849      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
29850      // Shared string table
29851      var ss=[],si={};
29852      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
29853      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29854      // Worksheet builder — each WS() call gets its own row counter R
29855      function WS(){
29856        var R=0,buf=[];
29857        function cl(c){return String.fromCharCode(65+c);}
29858        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
29859          '<v>'+S(v)+'</v></c>';}
29860        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
29861          (st?' s="'+st+'"':'')+'>'+
29862          '<v>'+(+v)+'</v></c>';}
29863        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
29864        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29865          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
29866          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
29867          '<sheetFormatPr defaultRowHeight="15"/>'+
29868          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
29869        return{sc:sc,nc:nc,row:row,xml:xml};
29870      }
29871      // Language breakdown
29872      var lm={};
29873      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;});
29874      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
29875      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
29876      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
29877      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
29878      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29879      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29880      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):'';}
29881      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
29882      // Summary sheet
29883      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
29884      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
29885      r1(s1(0,proj,2));
29886      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
29887      r1('');
29888      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
29889      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))));
29890      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))));
29891      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))));
29892      r1('');
29893      r1(s1(0,'FILE CHANGES',8));
29894      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
29895      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
29896      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
29897      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
29898      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
29899      if(langs.length){
29900        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
29901        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
29902        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)));});
29903      }
29904      r1('');r1(s1(0,'SCAN METADATA',8));
29905      r1(s1(1,_blabel)+s1(2,_clabel));
29906      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
29907      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
29908      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"/>');
29909      // File Delta sheet
29910      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
29911      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));
29912      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)));});
29913      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
29914      // Shared strings XML
29915      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
29916        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
29917        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
29918      // XLSX file map
29919      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
29920      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>',
29921        '_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>',
29922        '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>',
29923        '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>',
29924        '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>',
29925        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
29926      // ZIP packer — STORED (no compression), compatible with all XLSX readers
29927      var zparts=[],zcds=[],zoff=0,znf=0;
29928      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
29929       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
29930      ].forEach(function(name){
29931        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
29932        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]);
29933        var entry=new Uint8Array(lha.length+nb.length+sz);
29934        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
29935        zparts.push(entry);
29936        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));
29937        var cde=new Uint8Array(cda.length+nb.length);
29938        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
29939        zcds.push(cde);zoff+=entry.length;znf++;
29940      });
29941      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
29942      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]);
29943      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
29944      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
29945      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
29946      zout.set(new Uint8Array(ea),zpos);
29947      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
29948      var xurl=URL.createObjectURL(xblob);
29949      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
29950      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
29951      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
29952    }
29953    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;');}
29954    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
29955    function getExportFilename(ext){return _exportBase+'.'+ext;}
29956
29957    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 }}'};
29958    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;}
29959    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
29960    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
29961    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
29962    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
29963    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):'';}
29964    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
29965    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)]];}
29966    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
29967    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)];});}
29968    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
29969    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
29970
29971    // ── Chart HTML report ─────────────────────────────────────────────────────
29972    function slocChartReport(fname, sd, dr) {
29973      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
29974      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
29975      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
29976      function fmt(n){return Number(n).toLocaleString();}
29977      function px(n){return Math.round(n);}
29978      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
29979      // Language map
29980      var lm={};
29981      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;});
29982      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
29983
29984      // Builds onmouse* attrs for interactive tooltip on each SVG element
29985      function barTT(label,val){
29986        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
29987      }
29988
29989      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
29990      //    match the Language Code Delta column height) ────────────
29991      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'}];
29992      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
29993      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
29994      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
29995      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
29996      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"/>';}
29997      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
29998      c1mets.forEach(function(m,i){
29999        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30000        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30001        var gMax=Math.max(m.b,m.c)*1.15||1;
30002        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30003        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>';
30004        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))+'/>';
30005        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>';
30006        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))+'/>';
30007        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>';
30008        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>';
30009        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>';
30010      });
30011      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>';
30012      c1+='</svg>';
30013
30014      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30015      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'}];
30016      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30017      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30018      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30019      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30020      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30021      mets.forEach(function(m,i){
30022        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30023        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30024        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30025        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>';
30026        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30027        if(bw>=52){
30028          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>';
30029        }else{
30030          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30031          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>';
30032        }
30033      });
30034      c2+='</svg>';
30035
30036      // ── Chart 3: Language Code Delta ─────────────────────────────────────
30037      var c3='';
30038      if(langs.length){
30039        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30040        var C3W=550,c3LW=124,c3FW=52;
30041        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30042        var L3rH=30,C3H=langs.length*L3rH+20;
30043        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30044        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30045        langs.forEach(function(l,i){
30046          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30047          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30048          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30049          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>';
30050          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':''))+'/>';
30051          if(bw>=48){
30052            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>';
30053          }else{
30054            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30055            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>';
30056          }
30057          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>';
30058        });
30059        c3+='</svg>';
30060      }
30061
30062      // ── Chart 4: File Change Donut — centered pie with legend below
30063      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;});
30064      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30065      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30066      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">';
30067      var ang=-Math.PI/2;
30068      segs.forEach(function(s){
30069        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30070        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30071        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30072        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30073        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30074        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)+'%')+'/>';
30075        ang+=sw;
30076      });
30077      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>';
30078      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>';
30079      segs.forEach(function(s,i){
30080        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30081        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30082        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>';
30083      });
30084      c4+='</svg>';
30085
30086      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30087      var ttJs='var tt=document.getElementById("ox-tt");'+
30088        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30089        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30090        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30091        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30092        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30093        'function oxHT(){tt.style.display="none";}';
30094
30095      // body max-width keeps charts from inflating beyond design dimensions on
30096      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30097      // each chart's height blows up proportionally, breaking the one-page layout.
30098      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;}'+
30099        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30100        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30101        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30102        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30103        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30104        'svg{display:block;}'+
30105        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30106        '#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;}'+
30107        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30108      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30109        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30110        '<div id="ox-tt"><\/div>'+
30111        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
30112        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
30113        '<div class="two-col">'+
30114        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
30115        '<div class="leg">'+
30116        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30117        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30118        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30119        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
30120        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30121        '<\/div>'+
30122        '<div class="two-col">'+
30123        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30124        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30125        '<\/div>'+
30126        '<script>'+ttJs+'<\/script>'+
30127        '<\/body><\/html>';
30128      slocDownload(html, fname, 'text/html;charset=utf-8;');
30129    }
30130    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30131    window.buildDeltaChartsHtml = function() {
30132      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30133      var sd=_sd;
30134      var projEl=document.querySelector('[data-folder]');
30135      var proj=projEl?projEl.getAttribute('data-folder'):'';
30136      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30137      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30138      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30139      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30140      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";}';
30141      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);}';
30142      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30143        '<div id="ox-tt"><\/div>'+
30144        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30145        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30146        '<div class="two-col">'+
30147        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30148        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30149        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30150        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30151        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30152        '<\/div>'+
30153        '<div class="two-col">'+
30154        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30155        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30156        '<\/div>'+
30157        '<script>'+ttJs+'<\/script>'+
30158        '<\/body><\/html>';
30159    };
30160    // ── Inline delta charts ────────────────────────────────────────────────────
30161    var _icTT=document.getElementById('ic-tt');
30162    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30163    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';};
30164    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30165    window.addEventListener('blur',function(){window.icHT();});
30166    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30167    (function(){
30168      // Theme-aware palette — matches the canonical scheme used by /test-metrics
30169      // charts so every page renders bars/text/grid with the same colours and
30170      // adapts to dark mode (see Design section in CLAUDE.md).
30171      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30172      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30173      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30174      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30175      // washed) so the chart reads with the same weight as /test-metrics.
30176      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30177      var FADE=dark?'#524238':'#e6d0bf';
30178      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30179      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30180      function fmt(n){return Number(n).toLocaleString();}
30181      function px(n){return Math.round(n);}
30182      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30183      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30184      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);});}
30185      var dr=getDeltaExportRows(),sd=_sd,lm={};
30186      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;});
30187      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30188      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30189      // the bars are as tall as the (usually taller) Language Code Delta sibling that
30190      // shares the same grid row, instead of sitting short at the top.
30191      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}];
30192      function drawC1(){
30193        var C1W=600,C1H=188;
30194        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30195        if(host&&card&&host.clientWidth>0){
30196          var avW=host.clientWidth;
30197          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30198          var wantH=availPx*C1W/avW;
30199          if(wantH>C1H)C1H=wantH;
30200        }
30201        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30202        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30203        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"/>';}
30204        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30205        c1mets.forEach(function(m,i){
30206          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30207          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30208          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30209          var gMax=Math.max(m.b,m.c)*1.15||1;
30210          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30211          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>';
30212          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"/>';
30213          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>';
30214          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"/>';
30215          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>';
30216          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>';
30217          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>';
30218        });
30219        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>';
30220        c1+='</svg>';
30221        return c1;
30222      }
30223      var c1=drawC1();
30224      // Chart 2: Delta by Metric
30225      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}];
30226      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30227      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;
30228      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30229      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30230      mets.forEach(function(m,i){
30231        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);
30232        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>';
30233        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30234        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>';}
30235        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>';}
30236      });
30237      c2+='</svg>';
30238      // Chart 3: Language Code Delta
30239      var c3='';
30240      if(langs.length){
30241        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30242        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;
30243        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30244        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30245        langs.forEach(function(l,i){
30246          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);
30247          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>';
30248          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"/>';
30249          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>';}
30250          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>';}
30251          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>';
30252        });
30253        c3+='</svg>';
30254      }
30255      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30256      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30257      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;});
30258      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30259      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30260      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);
30261      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;
30262      if(segs.length===1){
30263        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30264        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+'"/>';
30265      } else {
30266        // Give every visible slice a small minimum sweep, taken from the largest
30267        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30268        // arc whose start and end points coincide, so SVG renders nothing (blank).
30269        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30270        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30271        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30272        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30273        segs.forEach(function(s,si){
30274          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30275          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);
30276          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);
30277          var pct=Math.round(s.v/tot*100);
30278          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"/>';
30279          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>';}
30280          ang+=sw;
30281        });
30282      }
30283      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30284      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30285      segs.forEach(function(s,i){
30286        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30287        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30288        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30289        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30290        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30291        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>';
30292        c4+='</g>';
30293      });
30294      c4+='</svg>';
30295      // Inject the fixed-height siblings first so the grid row settles to the (taller)
30296      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30297      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30298      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);}
30299      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30300      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30301      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30302
30303      // Compare Timeline chart (Baseline vs Current, 2 points)
30304      (function() {
30305        var activeCmpMetric='code';
30306        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30307        function renderCmpTL(metric, targetSvg, targetH) {
30308          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30309          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30310          svg.setAttribute('height',H);
30311          var pad={l:62,r:20,t:32,b:72};
30312          var dark=document.body.classList.contains('dark-theme');
30313          var cmpPts=[
30314            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30315            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30316          ];
30317          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30318          var valid=pts.filter(function(v){return v!=null;});
30319          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;}
30320          var minV=0,maxV=Math.max.apply(null,valid);
30321          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30322          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30323          var cx0=pad.l,cx1=pad.l+plotW;
30324          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30325          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30326          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30327          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30328          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30329          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();}
30330          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30331          var parts=[];
30332          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30333          for(var gi=0;gi<5;gi++){
30334            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30335            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30336            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30337          }
30338          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+'"/>');
30339          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"/>');
30340          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30341                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30342          dotPts.forEach(function(pt){
30343            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>');
30344            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"/>');
30345            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>');
30346            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>');
30347          });
30348          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30349          svg.setAttribute('viewBox','0 0 '+W+' '+H);
30350          svg.innerHTML=parts.join('');
30351          // Hover: crosshair + tooltip (matches multi-scan timeline)
30352          var cmpTT=document.getElementById('ic-tt');
30353          svg.onmousemove=function(e){
30354            var rect=svg.getBoundingClientRect();
30355            var scaleX=W/rect.width;
30356            var mouseX=(e.clientX-rect.left)*scaleX;
30357            var nearest=-1,minDist=Infinity;
30358            var cxArr=[cx0,cx1];
30359            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;}}
30360            if(nearest<0)return;
30361            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30362            var xhair=svg.querySelector('.cmp-xhair');
30363            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30364            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"/>';
30365            if(!cmpTT)return;
30366            var clbl=cmpPts[nearest].label;
30367            var scanLbl=nearest===0?'Baseline':'Current';
30368            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>';
30369            var bx=rect.left+(nc/W*rect.width)+18;
30370            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30371            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30372          };
30373          svg.onmouseleave=function(){
30374            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30375            if(cmpTT)cmpTT.style.display='none';
30376          };
30377        }
30378        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30379          btn.addEventListener('click',function(){
30380            activeCmpMetric=this.dataset.cmpMetric;
30381            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30382            this.classList.add('active');
30383            renderCmpTL(activeCmpMetric);
30384          });
30385        });
30386        var ttgl=document.getElementById('theme-toggle');
30387        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30388        if(typeof ResizeObserver!=='undefined'){
30389          var cmpSvg=document.getElementById('cmp-tl-svg');
30390          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30391        }
30392        // Expose the timeline renderer + current metric so the Full View modal can
30393        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30394        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30395        window.__sdGetMetric=function(){return activeCmpMetric;};
30396        renderCmpTL(activeCmpMetric);
30397      })();
30398
30399      // HTML legend hover -> highlight matching SVG bars within the SAME card only
30400      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30401        var metric=leg.getAttribute('data-highlight');
30402        var parentCard=leg.closest('.ic-card');
30403        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30404        if(!chartEl)return;
30405        leg.addEventListener('mouseenter',function(){
30406          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30407            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';}
30408            else{x.style.opacity='0.28';}
30409          });
30410        });
30411        leg.addEventListener('mouseleave',function(){
30412          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30413        });
30414      });
30415
30416      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30417      (function(){
30418        var ov=document.getElementById('ic-svg-modal-ov');
30419        var body=document.getElementById('ic-svg-modal-body');
30420        var ttl=document.getElementById('ic-svg-modal-title');
30421        var closeBtn=document.getElementById('ic-svg-modal-close');
30422        if(!ov||!body)return;
30423        function close(){
30424          ov.classList.remove('open');body.innerHTML='';
30425          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30426          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30427        }
30428        function open(srcId,title){
30429          var src=document.getElementById(srcId);if(!src)return;
30430          if(ttl)ttl.textContent=title||'';
30431          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30432          // snapshot stretches and loses interactivity. Re-render it live into the modal at
30433          // full size instead — keeps proportions, animation, crosshair, tooltip and the
30434          // metric tabs working exactly like the inline chart.
30435          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30436            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30437            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30438            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('');
30439            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>';
30440            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30441            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30442            ov.classList.add('open');
30443            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30444            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;}
30445            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30446              b.addEventListener('click',function(){
30447                if(!window.__sdFvTL)return;
30448                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30449                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30450                this.classList.add('active');
30451                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30452              });
30453            });
30454            return;
30455          }
30456          var card=src.closest('.ic-card');
30457          var legHtml='';
30458          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30459          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30460          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;}
30461          body.innerHTML=legHtml+inner;
30462          var svg=body.querySelector('svg');
30463          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30464          addTT(body);
30465          ov.classList.add('open');
30466        }
30467        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30468          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30469        });
30470        if(closeBtn)closeBtn.addEventListener('click',close);
30471        ov.addEventListener('click',function(e){if(e.target===ov)close();});
30472        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30473      })();
30474
30475      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30476    })();
30477  </script>
30478  {{ toast_assets|safe }}
30479  <script nonce="{{ csp_nonce }}">
30480  (function(){
30481    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'}];
30482    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);});}
30483    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30484    function init(){
30485      var btn=document.getElementById('settings-btn');if(!btn)return;
30486      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30487      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>';
30488      document.body.appendChild(m);
30489      var g=document.getElementById('scheme-grid');
30490      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);});
30491      var cl=document.getElementById('settings-close');
30492      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);});})();
30493      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');});
30494      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30495      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30496    }
30497    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30498  }());
30499  </script>
30500  <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]';
30501  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;}
30502  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>
30503</body>
30504</html>
30505"##,
30506    ext = "html"
30507)]
30508// Template structs need many bool fields to pass Askama rendering flags.
30509#[allow(clippy::struct_excessive_bools)]
30510struct CompareTemplate {
30511    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30512    loading_overlay: String,
30513    version: &'static str,
30514    project_label: String,
30515    baseline_git_commit: String,
30516    current_git_commit: String,
30517    baseline_run_id: String,
30518    current_run_id: String,
30519    baseline_run_id_short: String,
30520    current_run_id_short: String,
30521    baseline_timestamp: String,
30522    baseline_timestamp_utc_ms: i64,
30523    current_timestamp: String,
30524    current_timestamp_utc_ms: i64,
30525    project_path: String,
30526    baseline_code: u64,
30527    current_code: u64,
30528    code_lines_delta_str: String,
30529    code_lines_delta_class: String,
30530    baseline_files: u64,
30531    current_files: u64,
30532    files_analyzed_delta_str: String,
30533    files_analyzed_delta_class: String,
30534    baseline_comments: u64,
30535    current_comments: u64,
30536    comment_lines_delta_str: String,
30537    comment_lines_delta_class: String,
30538    baseline_code_fmt: String,
30539    current_code_fmt: String,
30540    baseline_files_fmt: String,
30541    current_files_fmt: String,
30542    baseline_comments_fmt: String,
30543    current_comments_fmt: String,
30544    code_lines_pct_str: String,
30545    files_analyzed_pct_str: String,
30546    comment_lines_pct_str: String,
30547    code_lines_added: i64,
30548    code_lines_removed: i64,
30549    /// Code lines residing in files modified between the two scans (current-scan counts).
30550    code_lines_modified: i64,
30551    /// Code lines residing in files identical between the two scans.
30552    code_lines_unmodified: i64,
30553    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30554    new_scope: bool,
30555    churn_rate_str: String,
30556    churn_rate_class: String,
30557    scope_flag: bool,
30558    files_added: usize,
30559    files_removed: usize,
30560    files_modified: usize,
30561    files_unchanged: usize,
30562    file_rows: Vec<CompareFileDeltaRow>,
30563    baseline_git_author: Option<String>,
30564    current_git_author: Option<String>,
30565    baseline_git_branch: String,
30566    current_git_branch: String,
30567    baseline_git_tags: Option<String>,
30568    current_git_tags: Option<String>,
30569    baseline_git_commit_date: Option<String>,
30570    current_git_commit_date: Option<String>,
30571    project_name: String,
30572    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30573    submodule_options: Vec<String>,
30574    /// True when either run has submodule data — controls whether the scope bar is shown.
30575    has_any_submodule_data: bool,
30576    /// The submodule currently being compared, if the `sub` query param was provided.
30577    active_submodule: Option<String>,
30578    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
30579    super_scope_active: bool,
30580    csp_nonce: String,
30581    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
30582    toast_assets: String,
30583    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
30584    coverage_delta_card: String,
30585    baseline_test_count: u64,
30586    current_test_count: u64,
30587    baseline_coverage_pct: Option<f64>,
30588    current_coverage_pct: Option<f64>,
30589}
30590
30591// ── LoginTemplate ──────────────────────────────────────────────────────────────
30592
30593#[derive(Template)]
30594#[template(
30595    source = r##"
30596<!doctype html>
30597<html lang="en">
30598<head>
30599  <meta charset="utf-8">
30600  <meta name="viewport" content="width=device-width, initial-scale=1">
30601  <title>OxideSLOC | Sign In</title>
30602  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30603  <style nonce="{{ csp_nonce }}">
30604    :root {
30605      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30606      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30607      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30608      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30609    }
30610    *{box-sizing:border-box;}
30611    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);}
30612    .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);}
30613    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30614    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30615    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30616    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30617    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30618    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30619    .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;}
30620    @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));}}
30621    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30622    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30623    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30624    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30625    .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;}
30626    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30627    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;}
30628    input[type=password]:focus{border-color:var(--oxide);}
30629    .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;}
30630    .btn:hover{opacity:.88;}
30631    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30632    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30633  </style>
30634</head>
30635<body>
30636  <div class="background-watermarks" aria-hidden="true">
30637    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30638    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30639    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30640    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30641    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30642    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30643    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30644  </div>
30645  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30646<nav class="top-nav">
30647  <a class="brand" href="/">
30648    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30649    <span class="brand-title">OxideSLOC</span>
30650  </a>
30651</nav>
30652<main class="page">
30653  <div class="card">
30654    <h1>Sign In</h1>
30655    <p class="subtitle">Enter the API key printed when the server started.</p>
30656    {% if has_error %}
30657    <div class="error">Incorrect API key — please try again.</div>
30658    {% endif %}
30659    <form method="POST" action="/auth/login">
30660      <input type="hidden" name="next" value="{{ next_url|e }}">
30661      <label for="key">API Key</label>
30662      <input id="key" type="password" name="key" autocomplete="current-password"
30663             placeholder="Paste your API key here" autofocus>
30664      <button type="submit" class="btn">Sign In</button>
30665    </form>
30666    <p class="hint">
30667      The API key was printed in the terminal when the server started.<br>
30668      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
30669      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
30670    </p>
30671  </div>
30672</main>
30673<script nonce="{{ csp_nonce }}">
30674(function() {
30675  (function randomizeWatermarks() {
30676    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
30677    if (!wms.length) return;
30678    var placed = [];
30679    function tooClose(top, left) {
30680      for (var i = 0; i < placed.length; i++) {
30681        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
30682        if (dt < 16 && dl < 12) return true;
30683      }
30684      return false;
30685    }
30686    function pick(leftBand) {
30687      for (var attempt = 0; attempt < 50; attempt++) {
30688        var top = Math.random() * 88 + 2;
30689        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30690        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
30691      }
30692      var top = Math.random() * 88 + 2;
30693      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
30694      placed.push([top, left]); return [top, left];
30695    }
30696    var half = Math.floor(wms.length / 2);
30697    wms.forEach(function (img, i) {
30698      var pos = pick(i < half);
30699      var size = Math.floor(Math.random() * 100 + 120);
30700      var rot = (Math.random() * 360).toFixed(1);
30701      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
30702      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;
30703    });
30704  })();
30705  (function spawnCodeParticles() {
30706    var container = document.getElementById('code-particles');
30707    if (!container) return;
30708    var snippets = [
30709      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
30710      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
30711      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
30712      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
30713      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
30714    ];
30715    var count = 38;
30716    for (var i = 0; i < count; i++) {
30717      (function(idx) {
30718        var el = document.createElement('span');
30719        el.className = 'code-particle';
30720        el.textContent = snippets[idx % snippets.length];
30721        var left = Math.random() * 94 + 2;
30722        var top = Math.random() * 88 + 6;
30723        var dur = (Math.random() * 10 + 9).toFixed(1);
30724        var delay = (Math.random() * 18).toFixed(1);
30725        var rot = (Math.random() * 26 - 13).toFixed(1);
30726        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
30727        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
30728        container.appendChild(el);
30729      })(i);
30730    }
30731  })();
30732})();
30733</script>
30734</body>
30735</html>
30736"##,
30737    ext = "html"
30738)]
30739pub(crate) struct LoginTemplate {
30740    pub(crate) csp_nonce: String,
30741    pub(crate) has_error: bool,
30742    pub(crate) next_url: String,
30743    pub(crate) lockout_threshold: u32,
30744}
30745
30746// ── REST API reference page ────────────────────────────────────────────────────
30747
30748#[derive(Template)]
30749#[template(
30750    source = r##"
30751<!doctype html>
30752<html lang="en">
30753<head>
30754  <meta charset="utf-8">
30755  <meta name="viewport" content="width=device-width, initial-scale=1">
30756  <title>OxideSLOC — REST API Reference</title>
30757  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30758  <style nonce="{{ csp_nonce }}">
30759    :root {
30760      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30761      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30762      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30763      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30764      --success:#16a34a;
30765    }
30766    body.dark-theme {
30767      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
30768      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
30769    }
30770    *{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;}
30771    .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);}
30772    .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;}
30773    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
30774    .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));}
30775    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
30776    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
30777    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
30778    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
30779    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30780    @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; } }
30781    .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;}
30782    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
30783    .nav-pill.active{background:rgba(255,255,255,0.22);}
30784    .nav-dropdown{position:relative;display:inline-flex;}
30785    .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;}
30786    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
30787    .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;}
30788    .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;}
30789    .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);}
30790    .nav-dropdown-menu a:last-child{border-bottom:none;}
30791    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
30792    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
30793    .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;}
30794    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30795    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30796    .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;}
30797    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30798    .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);}
30799    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
30800    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
30801    .settings-modal-body{padding:14px 16px 16px;}
30802    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30803    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30804    .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;}
30805    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30806    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30807    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30808    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30809    .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;}
30810    .tz-select:focus{border-color:var(--oxide);}
30811    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
30812    .page-header{margin-bottom:28px;}
30813    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
30814    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
30815    .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;}
30816    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
30817    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
30818    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
30819    .callout strong{font-weight:800;}
30820    .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;}
30821    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
30822    .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;}
30823    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
30824    .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;}
30825    body.dark-theme .base-url-value{color:var(--accent);}
30826    .section{margin-bottom:36px;}
30827    .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);}
30828    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
30829    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
30830    .ep-header:hover{background:var(--surface-2);}
30831    .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;}
30832    .method.get{background:#dcfce7;color:#166534;}
30833    .method.post{background:#dbeafe;color:#1e40af;}
30834    .method.delete{background:#fee2e2;color:#991b1b;}
30835    body.dark-theme .method.get{background:#14532d;color:#86efac;}
30836    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
30837    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
30838    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
30839    .ep-path .param{color:var(--oxide-2);}
30840    body.dark-theme .ep-path .param{color:var(--oxide);}
30841    .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;}
30842    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
30843    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
30844    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
30845    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
30846    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
30847    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
30848    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
30849    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
30850    .ep-card.open .chevron{transform:rotate(180deg);}
30851    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
30852    .ep-card.open .ep-body{display:block;}
30853    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
30854    .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;}
30855    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
30856    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
30857    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30858    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
30859    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);}
30860    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
30861    table.params tr:last-child td{border-bottom:none;}
30862    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
30863    .pt-type{color:var(--muted-2);font-size:12px;}
30864    .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;}
30865    .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;}
30866    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
30867    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
30868    details.schema{margin-bottom:14px;}
30869    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;}
30870    details.schema summary:hover{color:var(--text);}
30871    .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;}
30872    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
30873    .curl-wrap{position:relative;}
30874    .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;}
30875    .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;}
30876    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
30877    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
30878    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
30879    .webhook-note a{color:var(--accent-2);text-decoration:none;}
30880    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30881    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30882    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30883    .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;}
30884    @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));}}
30885    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30886    .site-footer a{color:var(--muted);}
30887  </style>
30888</head>
30889<body>
30890  <div class="background-watermarks" aria-hidden="true">
30891    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30892    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30893    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30894    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30895    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30896    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30897    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30898  </div>
30899  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30900  <div class="top-nav">
30901    <div class="top-nav-inner">
30902      <a class="brand" href="/">
30903        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30904        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
30905      </a>
30906      <div class="nav-right">
30907        <a class="nav-pill" href="/">Home</a>
30908        <div class="nav-dropdown">
30909          <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>
30910          <div class="nav-dropdown-menu">
30911            <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>
30912          </div>
30913        </div>
30914        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30915        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30916        <div class="nav-dropdown">
30917          <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>
30918          <div class="nav-dropdown-menu">
30919            <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>
30920          </div>
30921        </div>
30922        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30923          <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>
30924        </button>
30925        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30926          <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>
30927          <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>
30928        </button>
30929      </div>
30930    </div>
30931  </div>
30932
30933  <div class="page">
30934    <div class="page-header">
30935      <h1 class="page-title">REST API Reference</h1>
30936      <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>
30937    </div>
30938
30939    {% if has_api_key %}
30940    <div class="callout key-set">
30941      <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>
30942      <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>
30943    </div>
30944    {% else %}
30945    <div class="callout no-key">
30946      <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>
30947      <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>
30948    </div>
30949    {% endif %}
30950
30951    <div class="base-url-bar">
30952      <span class="base-url-label">Base URL</span>
30953      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
30954    </div>
30955
30956    <!-- Health -->
30957    <div class="section">
30958      <h2 class="section-title">Health &amp; Status</h2>
30959      <div class="ep-card">
30960        <div class="ep-header">
30961          <span class="method get">GET</span>
30962          <span class="ep-path">/healthz</span>
30963          <span class="auth-badge public">Public</span>
30964          <span class="ep-desc">Server liveness check</span>
30965          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30966        </div>
30967        <div class="ep-body">
30968          <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>
30969          <p class="params-heading">Response</p>
30970          <div class="schema-block">200 OK
30971Content-Type: text/plain
30972
30973ok</div>
30974          <p class="curl-heading">Example</p>
30975          <div class="curl-wrap">
30976            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
30977            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
30978          </div>
30979        </div>
30980      </div>
30981    </div>
30982
30983    <!-- Badges -->
30984    <div class="section">
30985      <h2 class="section-title">Badges</h2>
30986      <div class="ep-card">
30987        <div class="ep-header">
30988          <span class="method get">GET</span>
30989          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
30990          <span class="auth-badge public">Public</span>
30991          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
30992          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
30993        </div>
30994        <div class="ep-body">
30995          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
30996          <p class="params-heading">Path Parameters</p>
30997          <table class="params">
30998            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
30999            <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>
31000          </table>
31001          <p class="curl-heading">Example</p>
31002          <div class="curl-wrap">
31003            <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>
31004            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31005          </div>
31006        </div>
31007      </div>
31008    </div>
31009
31010    <!-- Metrics -->
31011    <div class="section">
31012      <h2 class="section-title">Metrics</h2>
31013
31014      <div class="ep-card">
31015        <div class="ep-header">
31016          <span class="method get">GET</span>
31017          <span class="ep-path">/api/metrics/latest</span>
31018          <span class="auth-badge protected">Protected</span>
31019          <span class="ep-desc">Latest scan metrics (JSON)</span>
31020          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31021        </div>
31022        <div class="ep-body">
31023          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31024          <details class="schema"><summary>Response schema</summary>
31025<div class="schema-block">{
31026  "run_id":    string,        // UUID
31027  "timestamp": string,        // ISO-8601 UTC
31028  "project":   string,        // scanned root path
31029  "summary": {
31030    "files_analyzed":       number,
31031    "files_skipped":        number,
31032    "code_lines":           number,
31033    "comment_lines":        number,
31034    "blank_lines":          number,
31035    "total_physical_lines": number,
31036    "functions":            number,
31037    "classes":              number,
31038    "variables":            number,
31039    "imports":              number
31040  },
31041  "languages": [
31042    { "name": string, "files": number, "code_lines": number,
31043      "comment_lines": number, "blank_lines": number,
31044      "functions": number, "classes": number,
31045      "variables": number, "imports": number }
31046  ]
31047}</div></details>
31048          <p class="curl-heading">Example</p>
31049          <div class="curl-wrap">
31050            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31051  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31052            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31053          </div>
31054        </div>
31055      </div>
31056
31057      <div class="ep-card">
31058        <div class="ep-header">
31059          <span class="method get">GET</span>
31060          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31061          <span class="auth-badge protected">Protected</span>
31062          <span class="ep-desc">Metrics for a specific run</span>
31063          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31064        </div>
31065        <div class="ep-body">
31066          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31067          <p class="params-heading">Path Parameters</p>
31068          <table class="params">
31069            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31070            <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>
31071          </table>
31072          <p class="curl-heading">Example</p>
31073          <div class="curl-wrap">
31074            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31075  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
31076            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31077          </div>
31078        </div>
31079      </div>
31080
31081      <div class="ep-card">
31082        <div class="ep-header">
31083          <span class="method get">GET</span>
31084          <span class="ep-path">/api/metrics/history</span>
31085          <span class="auth-badge protected">Protected</span>
31086          <span class="ep-desc">Paginated scan history</span>
31087          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31088        </div>
31089        <div class="ep-body">
31090          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31091          <p class="params-heading">Query Parameters</p>
31092          <table class="params">
31093            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31094            <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>
31095            <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>
31096          </table>
31097          <details class="schema"><summary>Response schema</summary>
31098<div class="schema-block">[{
31099  "run_id":         string,
31100  "timestamp":      string,   // ISO-8601 UTC
31101  "commit":         string | null,
31102  "branch":         string | null,
31103  "tags":           string[],
31104  "code_lines":     number,
31105  "comment_lines":  number,
31106  "blank_lines":    number,
31107  "physical_lines": number,
31108  "files_analyzed": number,
31109  "project_label":  string,
31110  "html_url":       string | null
31111}]</div></details>
31112          <p class="curl-heading">Example</p>
31113          <div class="curl-wrap">
31114            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31115  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31116            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31117          </div>
31118        </div>
31119      </div>
31120
31121      <div class="ep-card">
31122        <div class="ep-header">
31123          <span class="method get">GET</span>
31124          <span class="ep-path">/api/project-history</span>
31125          <span class="auth-badge protected">Protected</span>
31126          <span class="ep-desc">Project-level scan summary</span>
31127          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31128        </div>
31129        <div class="ep-body">
31130          <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>
31131          <p class="params-heading">Query Parameters</p>
31132          <table class="params">
31133            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31134            <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>
31135          </table>
31136          <details class="schema"><summary>Response schema</summary>
31137<div class="schema-block">{
31138  "scan_count":           number,
31139  "last_scan_id":         string | null,
31140  "last_scan_timestamp":  string | null,  // ISO-8601
31141  "last_scan_code_lines": number | null,
31142  "last_git_branch":      string | null,
31143  "last_git_commit":      string | null
31144}</div></details>
31145          <p class="curl-heading">Example</p>
31146          <div class="curl-wrap">
31147            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31148  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31149            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31150          </div>
31151        </div>
31152      </div>
31153
31154      <div class="ep-card">
31155        <div class="ep-header">
31156          <span class="method get">GET</span>
31157          <span class="ep-path">/api/metrics/submodules</span>
31158          <span class="auth-badge protected">Protected</span>
31159          <span class="ep-desc">List known git submodules across scans</span>
31160          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31161        </div>
31162        <div class="ep-body">
31163          <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>
31164          <p class="params-heading">Query Parameters</p>
31165          <table class="params">
31166            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31167            <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>
31168          </table>
31169          <details class="schema"><summary>Response schema</summary>
31170<div class="schema-block">[{
31171  "name":          string,  // submodule name
31172  "relative_path": string   // path relative to the project root
31173}]</div></details>
31174          <p class="curl-heading">Example</p>
31175          <div class="curl-wrap">
31176            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31177  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31178            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31179          </div>
31180        </div>
31181      </div>
31182    </div>
31183
31184    <!-- Async Run Status -->
31185    <div class="section">
31186      <h2 class="section-title">Async Run Status</h2>
31187
31188      <div class="ep-card">
31189        <div class="ep-header">
31190          <span class="method get">GET</span>
31191          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31192          <span class="auth-badge protected">Protected</span>
31193          <span class="ep-desc">Poll scan completion</span>
31194          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31195        </div>
31196        <div class="ep-body">
31197          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31198          <details class="schema"><summary>Response schema</summary>
31199<div class="schema-block">// Running
31200{ "state": "running",  "elapsed_secs": number }
31201
31202// Complete
31203{ "state": "complete", "run_id": string }
31204
31205// Failed
31206{ "state": "failed",   "message": string }</div></details>
31207          <p class="curl-heading">Example</p>
31208          <div class="curl-wrap">
31209            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31210  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
31211            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31212          </div>
31213        </div>
31214      </div>
31215
31216      <div class="ep-card">
31217        <div class="ep-header">
31218          <span class="method get">GET</span>
31219          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31220          <span class="auth-badge protected">Protected</span>
31221          <span class="ep-desc">Poll PDF generation readiness</span>
31222          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31223        </div>
31224        <div class="ep-body">
31225          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31226          <details class="schema"><summary>Response schema</summary>
31227<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31228          <p class="curl-heading">Example</p>
31229          <div class="curl-wrap">
31230            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31231  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
31232            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31233          </div>
31234        </div>
31235      </div>
31236
31237      <div class="ep-card">
31238        <div class="ep-header">
31239          <span class="method post">POST</span>
31240          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31241          <span class="auth-badge protected">Protected</span>
31242          <span class="ep-desc">Cancel a running scan</span>
31243          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31244        </div>
31245        <div class="ep-body">
31246          <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>
31247          <p class="curl-heading">Example</p>
31248          <div class="curl-wrap">
31249            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31250  -H "Authorization: Bearer $SLOC_API_KEY" \
31251  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
31252            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31253          </div>
31254        </div>
31255      </div>
31256    </div>
31257
31258    <!-- Run Management -->
31259    <div class="section">
31260      <h2 class="section-title">Run Management</h2>
31261
31262      <div class="ep-card">
31263        <div class="ep-header">
31264          <span class="method get">GET</span>
31265          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31266          <span class="auth-badge protected">Protected</span>
31267          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31268          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31269        </div>
31270        <div class="ep-body">
31271          <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>
31272          <p class="params-heading">Path Parameters</p>
31273          <table class="params">
31274            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31275            <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>
31276          </table>
31277          <details class="schema"><summary>Response</summary>
31278<div class="schema-block">200 OK — Content-Type: application/zip
31279Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
31280
31281404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
31282          <p class="curl-heading">Example</p>
31283          <div class="curl-wrap">
31284            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31285  -o run.zip \
31286  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
31287            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31288          </div>
31289        </div>
31290      </div>
31291
31292      <div class="ep-card">
31293        <div class="ep-header">
31294          <span class="method delete">DELETE</span>
31295          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31296          <span class="auth-badge protected">Protected</span>
31297          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31298          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31299        </div>
31300        <div class="ep-body">
31301          <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>
31302          <p class="params-heading">Path Parameters</p>
31303          <table class="params">
31304            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31305            <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>
31306          </table>
31307          <details class="schema"><summary>Response</summary>
31308<div class="schema-block">204 No Content — run successfully deleted
31309
31310500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
31311          <p class="curl-heading">Example</p>
31312          <div class="curl-wrap">
31313            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31314  -H "Authorization: Bearer $SLOC_API_KEY" \
31315  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
31316            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31317          </div>
31318        </div>
31319      </div>
31320
31321      <div class="ep-card">
31322        <div class="ep-header">
31323          <span class="method post">POST</span>
31324          <span class="ep-path">/api/runs/cleanup</span>
31325          <span class="auth-badge protected">Protected</span>
31326          <span class="ep-desc">Bulk delete runs older than N days</span>
31327          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31328        </div>
31329        <div class="ep-body">
31330          <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>
31331          <p class="params-heading">Request Body (application/json)</p>
31332          <table class="params">
31333            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31334            <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>
31335          </table>
31336          <details class="schema"><summary>Response schema</summary>
31337<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
31338          <p class="curl-heading">Example — delete runs older than 60 days</p>
31339          <div class="curl-wrap">
31340            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31341  -H "Authorization: Bearer $SLOC_API_KEY" \
31342  -H "Content-Type: application/json" \
31343  -d '{"older_than_days":60}' \
31344  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31345            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31346          </div>
31347        </div>
31348      </div>
31349    </div>
31350
31351    <!-- Retention Policy -->
31352    <div class="section">
31353      <h2 class="section-title">Retention Policy</h2>
31354
31355      <div class="ep-card">
31356        <div class="ep-header">
31357          <span class="method get">GET</span>
31358          <span class="ep-path">/api/cleanup-policy</span>
31359          <span class="auth-badge protected">Protected</span>
31360          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31361          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31362        </div>
31363        <div class="ep-body">
31364          <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>
31365          <details class="schema"><summary>Response schema</summary>
31366<div class="schema-block">{
31367  "policy": {
31368    "enabled":       boolean,
31369    "max_age_days":  number | null,   // delete runs older than N days
31370    "max_run_count": number | null,   // keep only the N most recent runs
31371    "interval_hours": number          // hours between background passes
31372  } | null,
31373  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
31374  "last_run_deleted": number | null   // runs deleted in last pass
31375}</div></details>
31376          <p class="curl-heading">Example</p>
31377          <div class="curl-wrap">
31378            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31379  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31380            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31381          </div>
31382        </div>
31383      </div>
31384
31385      <div class="ep-card">
31386        <div class="ep-header">
31387          <span class="method post">POST</span>
31388          <span class="ep-path">/api/cleanup-policy</span>
31389          <span class="auth-badge protected">Protected</span>
31390          <span class="ep-desc">Save or update the retention policy</span>
31391          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31392        </div>
31393        <div class="ep-body">
31394          <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>
31395          <p class="params-heading">Request Body (application/json)</p>
31396          <table class="params">
31397            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31398            <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>
31399            <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>
31400            <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>
31401            <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>
31402          </table>
31403          <details class="schema"><summary>Response</summary>
31404<div class="schema-block">204 No Content — policy saved and task (re)started
31405
31406500 Internal Server Error — { "error": string }</div></details>
31407          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31408          <div class="curl-wrap">
31409            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31410  -H "Authorization: Bearer $SLOC_API_KEY" \
31411  -H "Content-Type: application/json" \
31412  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31413  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31414            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31415          </div>
31416        </div>
31417      </div>
31418
31419      <div class="ep-card">
31420        <div class="ep-header">
31421          <span class="method post">POST</span>
31422          <span class="ep-path">/api/cleanup-policy/run-now</span>
31423          <span class="auth-badge protected">Protected</span>
31424          <span class="ep-desc">Trigger an immediate cleanup pass</span>
31425          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31426        </div>
31427        <div class="ep-body">
31428          <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>
31429          <details class="schema"><summary>Response schema</summary>
31430<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
31431          <p class="curl-heading">Example</p>
31432          <div class="curl-wrap">
31433            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31434  -H "Authorization: Bearer $SLOC_API_KEY" \
31435  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31436            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31437          </div>
31438        </div>
31439      </div>
31440
31441      <div class="ep-card">
31442        <div class="ep-header">
31443          <span class="method delete">DELETE</span>
31444          <span class="ep-path">/api/cleanup-policy</span>
31445          <span class="auth-badge protected">Protected</span>
31446          <span class="ep-desc">Remove the retention policy and stop the background task</span>
31447          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31448        </div>
31449        <div class="ep-body">
31450          <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>
31451          <details class="schema"><summary>Response</summary>
31452<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31453          <p class="curl-heading">Example</p>
31454          <div class="curl-wrap">
31455            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31456  -H "Authorization: Bearer $SLOC_API_KEY" \
31457  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31458            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31459          </div>
31460        </div>
31461      </div>
31462    </div>
31463
31464    <!-- Scan Profiles -->
31465    <div class="section">
31466      <h2 class="section-title">Scan Profiles</h2>
31467
31468      <div class="ep-card">
31469        <div class="ep-header">
31470          <span class="method get">GET</span>
31471          <span class="ep-path">/api/scan-profiles</span>
31472          <span class="auth-badge protected">Protected</span>
31473          <span class="ep-desc">List saved scan profiles</span>
31474          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31475        </div>
31476        <div class="ep-body">
31477          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31478          <details class="schema"><summary>Response schema</summary>
31479<div class="schema-block">{
31480  "profiles": [{
31481    "id":         string,   // UUID
31482    "name":       string,
31483    "created_at": string,   // ISO-8601
31484    "params":     object
31485  }]
31486}</div></details>
31487          <p class="curl-heading">Example</p>
31488          <div class="curl-wrap">
31489            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31490  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31491            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31492          </div>
31493        </div>
31494      </div>
31495
31496      <div class="ep-card">
31497        <div class="ep-header">
31498          <span class="method post">POST</span>
31499          <span class="ep-path">/api/scan-profiles</span>
31500          <span class="auth-badge protected">Protected</span>
31501          <span class="ep-desc">Save a scan profile</span>
31502          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31503        </div>
31504        <div class="ep-body">
31505          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31506          <p class="params-heading">Request Body (application/json)</p>
31507          <table class="params">
31508            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31509            <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>
31510            <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>
31511          </table>
31512          <details class="schema"><summary>Response schema</summary>
31513<div class="schema-block">{ "ok": true }</div></details>
31514          <p class="curl-heading">Example</p>
31515          <div class="curl-wrap">
31516            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31517  -H "Authorization: Bearer $SLOC_API_KEY" \
31518  -H "Content-Type: application/json" \
31519  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31520  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31521            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31522          </div>
31523        </div>
31524      </div>
31525
31526      <div class="ep-card">
31527        <div class="ep-header">
31528          <span class="method delete">DELETE</span>
31529          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31530          <span class="auth-badge protected">Protected</span>
31531          <span class="ep-desc">Delete a scan profile</span>
31532          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31533        </div>
31534        <div class="ep-body">
31535          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31536          <p class="params-heading">Path Parameters</p>
31537          <table class="params">
31538            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31539            <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>
31540          </table>
31541          <details class="schema"><summary>Response schema</summary>
31542<div class="schema-block">{ "ok": true }</div></details>
31543          <p class="curl-heading">Example</p>
31544          <div class="curl-wrap">
31545            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31546  -H "Authorization: Bearer $SLOC_API_KEY" \
31547  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31548            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31549          </div>
31550        </div>
31551      </div>
31552    </div>
31553
31554    <!-- Scheduled Scans -->
31555    <div class="section">
31556      <h2 class="section-title">Scheduled Scans</h2>
31557
31558      <div class="ep-card">
31559        <div class="ep-header">
31560          <span class="method get">GET</span>
31561          <span class="ep-path">/api/schedules</span>
31562          <span class="auth-badge protected">Protected</span>
31563          <span class="ep-desc">List configured schedules</span>
31564          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31565        </div>
31566        <div class="ep-body">
31567          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31568          <p class="curl-heading">Example</p>
31569          <div class="curl-wrap">
31570            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31571  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31572            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31573          </div>
31574        </div>
31575      </div>
31576
31577      <div class="ep-card">
31578        <div class="ep-header">
31579          <span class="method post">POST</span>
31580          <span class="ep-path">/api/schedules</span>
31581          <span class="auth-badge protected">Protected</span>
31582          <span class="ep-desc">Create a schedule</span>
31583          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31584        </div>
31585        <div class="ep-body">
31586          <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>
31587          <p class="curl-heading">Example</p>
31588          <div class="curl-wrap">
31589            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31590  -H "Authorization: Bearer $SLOC_API_KEY" \
31591  -H "Content-Type: application/json" \
31592  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31593  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31594            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31595          </div>
31596        </div>
31597      </div>
31598
31599      <div class="ep-card">
31600        <div class="ep-header">
31601          <span class="method delete">DELETE</span>
31602          <span class="ep-path">/api/schedules</span>
31603          <span class="auth-badge protected">Protected</span>
31604          <span class="ep-desc">Delete a schedule</span>
31605          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31606        </div>
31607        <div class="ep-body">
31608          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31609          <p class="curl-heading">Example</p>
31610          <div class="curl-wrap">
31611            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31612  -H "Authorization: Bearer $SLOC_API_KEY" \
31613  -H "Content-Type: application/json" \
31614  -d '{"id":"&lt;schedule_id&gt;"}' \
31615  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31616            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31617          </div>
31618        </div>
31619      </div>
31620    </div>
31621
31622    <!-- Git Browser -->
31623    <div class="section">
31624      <h2 class="section-title">Git Browser</h2>
31625
31626      <div class="ep-card">
31627        <div class="ep-header">
31628          <span class="method get">GET</span>
31629          <span class="ep-path">/api/git/refs</span>
31630          <span class="auth-badge protected">Protected</span>
31631          <span class="ep-desc">List git refs for a repository</span>
31632          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31633        </div>
31634        <div class="ep-body">
31635          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31636          <p class="params-heading">Query Parameters</p>
31637          <table class="params">
31638            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31639            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31640          </table>
31641          <p class="curl-heading">Example</p>
31642          <div class="curl-wrap">
31643            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31644  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31645            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31646          </div>
31647        </div>
31648      </div>
31649
31650      <div class="ep-card">
31651        <div class="ep-header">
31652          <span class="method get">GET</span>
31653          <span class="ep-path">/api/git/scan-ref</span>
31654          <span class="auth-badge protected">Protected</span>
31655          <span class="ep-desc">SLOC-scan a specific git ref</span>
31656          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31657        </div>
31658        <div class="ep-body">
31659          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31660          <p class="params-heading">Query Parameters</p>
31661          <table class="params">
31662            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31663            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31664            <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
31665          </table>
31666          <p class="curl-heading">Example</p>
31667          <div class="curl-wrap">
31668            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31669  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
31670            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
31671          </div>
31672        </div>
31673      </div>
31674
31675      <div class="ep-card">
31676        <div class="ep-header">
31677          <span class="method get">GET</span>
31678          <span class="ep-path">/api/git/compare-refs</span>
31679          <span class="auth-badge protected">Protected</span>
31680          <span class="ep-desc">Compare SLOC across two git refs</span>
31681          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31682        </div>
31683        <div class="ep-body">
31684          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
31685          <p class="params-heading">Query Parameters</p>
31686          <table class="params">
31687            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31688            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31689            <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
31690            <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
31691          </table>
31692          <p class="curl-heading">Example</p>
31693          <div class="curl-wrap">
31694            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31695  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&amp;base=v1.0&amp;head=main"</pre>
31696            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
31697          </div>
31698        </div>
31699      </div>
31700    </div>
31701
31702    <!-- Webhooks -->
31703    <div class="section">
31704      <h2 class="section-title">Webhooks</h2>
31705      <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>
31706
31707      <div class="ep-card">
31708        <div class="ep-header">
31709          <span class="method post">POST</span>
31710          <span class="ep-path">/webhooks/github</span>
31711          <span class="auth-badge hmac">HMAC</span>
31712          <span class="ep-desc">GitHub push event receiver</span>
31713          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31714        </div>
31715        <div class="ep-body">
31716          <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>
31717          <p class="params-heading">Required Headers</p>
31718          <table class="params">
31719            <tr><th>Header</th><th>Value</th></tr>
31720            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
31721            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
31722            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31723          </table>
31724        </div>
31725      </div>
31726
31727      <div class="ep-card">
31728        <div class="ep-header">
31729          <span class="method post">POST</span>
31730          <span class="ep-path">/webhooks/gitlab</span>
31731          <span class="auth-badge hmac">HMAC</span>
31732          <span class="ep-desc">GitLab push event receiver</span>
31733          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31734        </div>
31735        <div class="ep-body">
31736          <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>
31737          <p class="params-heading">Required Headers</p>
31738          <table class="params">
31739            <tr><th>Header</th><th>Value</th></tr>
31740            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
31741            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
31742            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31743          </table>
31744        </div>
31745      </div>
31746
31747      <div class="ep-card">
31748        <div class="ep-header">
31749          <span class="method post">POST</span>
31750          <span class="ep-path">/webhooks/bitbucket</span>
31751          <span class="auth-badge hmac">HMAC</span>
31752          <span class="ep-desc">Bitbucket push event receiver</span>
31753          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31754        </div>
31755        <div class="ep-body">
31756          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
31757          <p class="params-heading">Required Headers</p>
31758          <table class="params">
31759            <tr><th>Header</th><th>Value</th></tr>
31760            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
31761            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
31762          </table>
31763        </div>
31764      </div>
31765    </div>
31766
31767    <!-- Config -->
31768    <div class="section">
31769      <h2 class="section-title">Config Import / Export</h2>
31770
31771      <div class="ep-card">
31772        <div class="ep-header">
31773          <span class="method get">GET</span>
31774          <span class="ep-path">/export-config</span>
31775          <span class="auth-badge protected">Protected</span>
31776          <span class="ep-desc">Export server configuration as JSON</span>
31777          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31778        </div>
31779        <div class="ep-body">
31780          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
31781          <p class="curl-heading">Example</p>
31782          <div class="curl-wrap">
31783            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31784  -o config.json \
31785  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
31786            <button class="curl-copy-btn" data-target="c-export">Copy</button>
31787          </div>
31788        </div>
31789      </div>
31790
31791      <div class="ep-card">
31792        <div class="ep-header">
31793          <span class="method post">POST</span>
31794          <span class="ep-path">/import-config</span>
31795          <span class="auth-badge protected">Protected</span>
31796          <span class="ep-desc">Import server configuration</span>
31797          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31798        </div>
31799        <div class="ep-body">
31800          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
31801          <p class="curl-heading">Example</p>
31802          <div class="curl-wrap">
31803            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
31804  -H "Authorization: Bearer $SLOC_API_KEY" \
31805  -H "Content-Type: application/json" \
31806  -d @config.json \
31807  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
31808            <button class="curl-copy-btn" data-target="c-import">Copy</button>
31809          </div>
31810        </div>
31811      </div>
31812    </div>
31813
31814    <!-- CI Ingest -->
31815    <div class="section">
31816      <h2 class="section-title">CI Ingest</h2>
31817
31818      <div class="ep-card">
31819        <div class="ep-header">
31820          <span class="method post">POST</span>
31821          <span class="ep-path">/api/ingest</span>
31822          <span class="auth-badge protected">Protected</span>
31823          <span class="ep-desc">Push a pre-computed scan result from CI</span>
31824          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31825        </div>
31826        <div class="ep-body">
31827          <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>
31828          <p class="params-heading">Query Parameters</p>
31829          <table class="params">
31830            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31831            <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>
31832          </table>
31833          <p class="params-heading">Request Body (application/json)</p>
31834          <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>
31835          <details class="schema"><summary>Response schema</summary>
31836<div class="schema-block">// 201 Created
31837{
31838  "run_id":   string,  // UUID of the ingested run
31839  "view_url": string   // relative URL to the report page
31840}</div></details>
31841          <p class="curl-heading">Example</p>
31842          <div class="curl-wrap">
31843            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
31844  -H "Authorization: Bearer $SLOC_API_KEY" \
31845  -H "Content-Type: application/json" \
31846  -d @result.json \
31847  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
31848            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
31849          </div>
31850        </div>
31851      </div>
31852    </div>
31853
31854    <!-- Artifact Download -->
31855    <div class="section">
31856      <h2 class="section-title">Artifact Download</h2>
31857
31858      <div class="ep-card">
31859        <div class="ep-header">
31860          <span class="method get">GET</span>
31861          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
31862          <span class="auth-badge protected">Protected</span>
31863          <span class="ep-desc">Download or view a scan artifact</span>
31864          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31865        </div>
31866        <div class="ep-body">
31867          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
31868          <p class="params-heading">Path Parameters</p>
31869          <table class="params">
31870            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31871            <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>
31872            <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>
31873          </table>
31874          <p class="params-heading">Query Parameters</p>
31875          <table class="params">
31876            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31877            <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>
31878          </table>
31879          <p class="curl-heading">Example — download JSON result</p>
31880          <div class="curl-wrap">
31881            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31882  -o result.json \
31883  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
31884            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
31885          </div>
31886        </div>
31887      </div>
31888    </div>
31889
31890    <!-- Embed Widget -->
31891    <div class="section">
31892      <h2 class="section-title">Embed Widget</h2>
31893
31894      <div class="ep-card">
31895        <div class="ep-header">
31896          <span class="method get">GET</span>
31897          <span class="ep-path">/embed/summary</span>
31898          <span class="auth-badge protected">Protected</span>
31899          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
31900          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31901        </div>
31902        <div class="ep-body">
31903          <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>
31904          <p class="params-heading">Query Parameters</p>
31905          <table class="params">
31906            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31907            <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>
31908            <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>
31909          </table>
31910          <p class="curl-heading">Example</p>
31911          <div class="curl-wrap">
31912            <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"
31913        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
31914            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
31915          </div>
31916        </div>
31917      </div>
31918    </div>
31919
31920    <!-- Confluence Integration -->
31921    <div class="section">
31922      <h2 class="section-title">Confluence Integration</h2>
31923
31924      <div class="ep-card">
31925        <div class="ep-header">
31926          <span class="method get">GET</span>
31927          <span class="ep-path">/api/confluence/config</span>
31928          <span class="auth-badge protected">Protected</span>
31929          <span class="ep-desc">Get current Confluence configuration</span>
31930          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31931        </div>
31932        <div class="ep-body">
31933          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
31934          <details class="schema"><summary>Response schema</summary>
31935<div class="schema-block">{
31936  "configured":     boolean,
31937  "tier":           "cloud" | "server",
31938  "base_url":       string,
31939  "username":       string,
31940  "api_token_set":  boolean,
31941  "space_key":      string,
31942  "parent_page_id": string | null,
31943  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
31944}</div></details>
31945          <p class="curl-heading">Example</p>
31946          <div class="curl-wrap">
31947            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31948  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31949            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
31950          </div>
31951        </div>
31952      </div>
31953
31954      <div class="ep-card">
31955        <div class="ep-header">
31956          <span class="method post">POST</span>
31957          <span class="ep-path">/api/confluence/config</span>
31958          <span class="auth-badge protected">Protected</span>
31959          <span class="ep-desc">Save Confluence configuration</span>
31960          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31961        </div>
31962        <div class="ep-body">
31963          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
31964          <p class="params-heading">Request Body (application/json)</p>
31965          <table class="params">
31966            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31967            <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>
31968            <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>
31969            <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>
31970            <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>
31971            <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>
31972            <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>
31973            <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>
31974          </table>
31975          <details class="schema"><summary>Response schema</summary>
31976<div class="schema-block">{ "ok": true }</div></details>
31977          <p class="curl-heading">Example</p>
31978          <div class="curl-wrap">
31979            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
31980  -H "Authorization: Bearer $SLOC_API_KEY" \
31981  -H "Content-Type: application/json" \
31982  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
31983  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
31984            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
31985          </div>
31986        </div>
31987      </div>
31988
31989      <div class="ep-card">
31990        <div class="ep-header">
31991          <span class="method post">POST</span>
31992          <span class="ep-path">/api/confluence/test</span>
31993          <span class="auth-badge protected">Protected</span>
31994          <span class="ep-desc">Test Confluence connection</span>
31995          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31996        </div>
31997        <div class="ep-body">
31998          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
31999          <details class="schema"><summary>Response schema</summary>
32000<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32001          <p class="curl-heading">Example</p>
32002          <div class="curl-wrap">
32003            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32004  -H "Authorization: Bearer $SLOC_API_KEY" \
32005  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32006            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32007          </div>
32008        </div>
32009      </div>
32010
32011      <div class="ep-card">
32012        <div class="ep-header">
32013          <span class="method post">POST</span>
32014          <span class="ep-path">/api/confluence/post</span>
32015          <span class="auth-badge protected">Protected</span>
32016          <span class="ep-desc">Publish a scan report to Confluence</span>
32017          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32018        </div>
32019        <div class="ep-body">
32020          <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>
32021          <p class="params-heading">Request Body (application/json)</p>
32022          <table class="params">
32023            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32024            <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>
32025            <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>
32026            <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>
32027          </table>
32028          <details class="schema"><summary>Response schema</summary>
32029<div class="schema-block">// 200 OK
32030{ "ok": true, "page_id": string }
32031
32032// 400 / 502 on error
32033{ "ok": false, "error": string }</div></details>
32034          <p class="curl-heading">Example</p>
32035          <div class="curl-wrap">
32036            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32037  -H "Authorization: Bearer $SLOC_API_KEY" \
32038  -H "Content-Type: application/json" \
32039  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
32040  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32041            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32042          </div>
32043        </div>
32044      </div>
32045
32046      <div class="ep-card">
32047        <div class="ep-header">
32048          <span class="method get">GET</span>
32049          <span class="ep-path">/api/confluence/wiki-markup</span>
32050          <span class="auth-badge protected">Protected</span>
32051          <span class="ep-desc">Get Confluence wiki markup for a run</span>
32052          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32053        </div>
32054        <div class="ep-body">
32055          <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>
32056          <p class="params-heading">Query Parameters</p>
32057          <table class="params">
32058            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32059            <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>
32060          </table>
32061          <p class="curl-heading">Example</p>
32062          <div class="curl-wrap">
32063            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32064  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
32065            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32066          </div>
32067        </div>
32068      </div>
32069    </div>
32070
32071    <!-- Authentication -->
32072    <div class="section">
32073      <h2 class="section-title">Authentication</h2>
32074      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32075
32076      <div class="ep-card">
32077        <div class="ep-header">
32078          <span class="method get">GET</span>
32079          <span class="ep-path">/auth/login</span>
32080          <span class="auth-badge public">Public</span>
32081          <span class="ep-desc">Login page</span>
32082          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32083        </div>
32084        <div class="ep-body">
32085          <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>
32086          <p class="params-heading">Query Parameters</p>
32087          <table class="params">
32088            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32089            <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>
32090            <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>
32091          </table>
32092        </div>
32093      </div>
32094
32095      <div class="ep-card">
32096        <div class="ep-header">
32097          <span class="method post">POST</span>
32098          <span class="ep-path">/auth/login</span>
32099          <span class="auth-badge public">Public</span>
32100          <span class="ep-desc">Submit credentials and get a session cookie</span>
32101          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32102        </div>
32103        <div class="ep-body">
32104          <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>
32105          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32106          <table class="params">
32107            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32108            <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>
32109            <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>
32110          </table>
32111          <p class="curl-heading">Example</p>
32112          <div class="curl-wrap">
32113            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32114  -d "key=$SLOC_API_KEY&amp;next=/" \
32115  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32116            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32117          </div>
32118        </div>
32119      </div>
32120    </div>
32121
32122    <!-- Coverage Suggestion -->
32123    <div class="section">
32124      <h2 class="section-title">Coverage Suggestion</h2>
32125
32126      <div class="ep-card">
32127        <div class="ep-header">
32128          <span class="method get">GET</span>
32129          <span class="ep-path">/api/suggest-coverage</span>
32130          <span class="auth-badge protected">Protected</span>
32131          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32132          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32133        </div>
32134        <div class="ep-body">
32135          <p class="ep-desc-full">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>
32136          <p class="params-heading">Query Parameters</p>
32137          <table class="params">
32138            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32139            <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>
32140          </table>
32141          <details class="schema"><summary>Response schema</summary>
32142<div class="schema-block">{
32143  "found": string | null,  // absolute path to the coverage file, if detected
32144  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32145  "hint":  string | null   // shell command to generate coverage if not found
32146}</div></details>
32147          <p class="curl-heading">Example</p>
32148          <div class="curl-wrap">
32149            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32150  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32151            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32152          </div>
32153        </div>
32154      </div>
32155    </div>
32156
32157  </div>
32158
32159  <footer class="site-footer">
32160    local code analysis - metrics, history and reports
32161    &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>
32162    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32163    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32164    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32165    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32166  </footer>
32167
32168  <script nonce="{{ csp_nonce }}">
32169    (function () {
32170      var base = window.location.origin;
32171      document.getElementById('base-url').textContent = base;
32172      document.querySelectorAll('.base-url-slot').forEach(function (el) {
32173        el.textContent = base;
32174      });
32175
32176      document.querySelectorAll('.ep-header').forEach(function (hdr) {
32177        hdr.addEventListener('click', function () {
32178          hdr.closest('.ep-card').classList.toggle('open');
32179        });
32180      });
32181
32182      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32183        btn.addEventListener('click', function () {
32184          var targetId = btn.dataset.target;
32185          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32186          if (!pre) return;
32187          navigator.clipboard.writeText(pre.textContent).then(function () {
32188            btn.textContent = 'Copied!';
32189            btn.classList.add('copied');
32190            setTimeout(function () {
32191              btn.textContent = 'Copy';
32192              btn.classList.remove('copied');
32193            }, 2000);
32194          });
32195        });
32196      });
32197
32198      var storageKey = 'oxide-sloc-theme';
32199      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32200      var themeBtn = document.getElementById('theme-toggle');
32201      if (themeBtn) {
32202        themeBtn.addEventListener('click', function () {
32203          var dark = document.body.classList.toggle('dark-theme');
32204          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32205        });
32206      }
32207      (function() {
32208        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'}];
32209        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);});}
32210        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32211        var btn=document.getElementById('settings-btn');if(!btn)return;
32212        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32213        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>';
32214        document.body.appendChild(m);
32215        var g=document.getElementById('scheme-grid');
32216        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);});
32217        var cl=document.getElementById('settings-close');
32218        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);});})();
32219        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');});
32220        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32221        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32222      })();
32223      (function randomizeWatermarks() {
32224        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32225        if (!wms.length) return;
32226        var placed = [];
32227        function tooClose(top, left) {
32228          for (var i = 0; i < placed.length; i++) {
32229            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32230            if (dt < 16 && dl < 12) return true;
32231          }
32232          return false;
32233        }
32234        function pick(leftBand) {
32235          for (var attempt = 0; attempt < 50; attempt++) {
32236            var top = Math.random() * 88 + 2;
32237            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32238            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32239          }
32240          var top = Math.random() * 88 + 2;
32241          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32242          placed.push([top, left]); return [top, left];
32243        }
32244        var half = Math.floor(wms.length / 2);
32245        wms.forEach(function (img, i) {
32246          var pos = pick(i < half);
32247          var size = Math.floor(Math.random() * 100 + 120);
32248          var rot = (Math.random() * 360).toFixed(1);
32249          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32250          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;
32251        });
32252      })();
32253      (function spawnCodeParticles() {
32254        var container = document.getElementById('code-particles');
32255        if (!container) return;
32256        var snippets = [
32257          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32258          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32259          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32260          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32261          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32262        ];
32263        var count = 38;
32264        for (var i = 0; i < count; i++) {
32265          (function(idx) {
32266            var el = document.createElement('span');
32267            el.className = 'code-particle';
32268            el.textContent = snippets[idx % snippets.length];
32269            var left = Math.random() * 94 + 2;
32270            var top = Math.random() * 88 + 6;
32271            var dur = (Math.random() * 10 + 9).toFixed(1);
32272            var delay = (Math.random() * 18).toFixed(1);
32273            var rot = (Math.random() * 26 - 13).toFixed(1);
32274            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32275            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32276            container.appendChild(el);
32277          })(i);
32278        }
32279      })();
32280    }());
32281  </script>
32282</body>
32283</html>
32284"##,
32285    ext = "html"
32286)]
32287struct ApiDocsTemplate {
32288    has_api_key: bool,
32289    csp_nonce: String,
32290    version: &'static str,
32291}
32292
32293#[cfg(test)]
32294mod form_config_tests {
32295    use super::*;
32296    use sloc_config::{
32297        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32298    };
32299
32300    fn blank_form() -> AnalyzeForm {
32301        AnalyzeForm {
32302            path: ".".to_string(),
32303            git_repo: None,
32304            git_ref: None,
32305            mixed_line_policy: None,
32306            python_docstrings_as_comments: None,
32307            generated_file_detection: None,
32308            minified_file_detection: None,
32309            vendor_directory_detection: None,
32310            include_lockfiles: None,
32311            binary_file_behavior: None,
32312            output_dir: None,
32313            report_title: None,
32314            report_header_footer: None,
32315            include_globs: None,
32316            exclude_globs: None,
32317            submodule_breakdown: None,
32318            coverage_file: None,
32319            continuation_line_policy: None,
32320            blank_in_block_comment_policy: None,
32321            count_compiler_directives: None,
32322            style_col_threshold: None,
32323            style_analysis_enabled: None,
32324            style_score_threshold: None,
32325            style_lang_scope: None,
32326            cocomo_mode: None,
32327            complexity_alert: None,
32328            exclude_duplicates: None,
32329            activity_window: None,
32330        }
32331    }
32332
32333    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32334        let mut cfg = sloc_config::AppConfig::default();
32335        apply_form_to_config(&mut cfg, form);
32336        cfg
32337    }
32338
32339    // ── activity_window (git hotspots — on by default) ──
32340
32341    #[test]
32342    fn extract_long_commit_picks_super_repo_by_short_prefix() {
32343        // A pretty-printed JSON tail containing several submodule git_commit_long
32344        // values plus the super-repo's; the helper must return the one whose hash
32345        // starts with the known short SHA, ignoring the others and any null value.
32346        let dir = tempfile::tempdir().unwrap();
32347        let path = dir.path().join("result.json");
32348        let body = r#"{
32349  "submodules": [
32350    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32351    { "git_commit_long": null }
32352  ],
32353  "git_commit_short": "4c2cd9b",
32354  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32355}"#;
32356        std::fs::write(&path, body).unwrap();
32357        assert_eq!(
32358            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32359            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32360        );
32361        // No match for an unrelated short SHA, and empty short yields None.
32362        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32363        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32364    }
32365
32366    #[test]
32367    fn activity_window_defaults_on_when_field_blank() {
32368        // Blank form field keeps the config default (90 days).
32369        let cfg = apply(&blank_form());
32370        assert_eq!(cfg.analysis.activity_window_days, Some(90));
32371    }
32372
32373    #[test]
32374    fn activity_window_override_sets_days() {
32375        let mut form = blank_form();
32376        form.activity_window = Some("30".to_string());
32377        let cfg = apply(&form);
32378        assert_eq!(cfg.analysis.activity_window_days, Some(30));
32379    }
32380
32381    #[test]
32382    fn activity_window_zero_disables() {
32383        // An explicit 0 from the form disables hotspots (overrides the default-on).
32384        let mut form = blank_form();
32385        form.activity_window = Some("0".to_string());
32386        let cfg = apply(&form);
32387        assert_eq!(cfg.analysis.activity_window_days, Some(0));
32388    }
32389
32390    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
32391
32392    #[test]
32393    fn python_docstrings_false_when_unchecked() {
32394        // Checkbox absent in form data (unchecked) → field must be false.
32395        let cfg = apply(&blank_form());
32396        assert!(
32397            !cfg.analysis.python_docstrings_as_comments,
32398            "absent python_docstrings_as_comments must map to false"
32399        );
32400    }
32401
32402    #[test]
32403    fn python_docstrings_true_when_checked() {
32404        // Browser sends "on" (no value= attr on the checkbox).
32405        let mut form = blank_form();
32406        form.python_docstrings_as_comments = Some("on".to_string());
32407        let cfg = apply(&form);
32408        assert!(cfg.analysis.python_docstrings_as_comments);
32409    }
32410
32411    #[test]
32412    fn python_docstrings_true_for_any_non_none_value() {
32413        // The handler uses .is_some() — any non-None value means "checked".
32414        let mut form = blank_form();
32415        form.python_docstrings_as_comments = Some("true".to_string());
32416        assert!(apply(&form).analysis.python_docstrings_as_comments);
32417    }
32418
32419    // ── submodule_breakdown (checkbox with value="enabled") ──
32420
32421    #[test]
32422    fn submodule_breakdown_false_when_unchecked() {
32423        let cfg = apply(&blank_form());
32424        assert!(
32425            !cfg.discovery.submodule_breakdown,
32426            "absent submodule_breakdown must map to false"
32427        );
32428    }
32429
32430    #[test]
32431    fn submodule_breakdown_true_when_value_enabled() {
32432        let mut form = blank_form();
32433        form.submodule_breakdown = Some("enabled".to_string());
32434        assert!(apply(&form).discovery.submodule_breakdown);
32435    }
32436
32437    #[test]
32438    fn submodule_breakdown_false_for_wrong_value() {
32439        // If somehow a value other than "enabled" is sent, it must still be false.
32440        let mut form = blank_form();
32441        form.submodule_breakdown = Some("on".to_string());
32442        assert!(
32443            !apply(&form).discovery.submodule_breakdown,
32444            "submodule_breakdown only becomes true for the exact value 'enabled'"
32445        );
32446    }
32447
32448    // ── generated_file_detection (select: "enabled" | "disabled") ──
32449
32450    #[test]
32451    fn generated_detection_true_when_enabled() {
32452        let mut form = blank_form();
32453        form.generated_file_detection = Some("enabled".to_string());
32454        assert!(apply(&form).analysis.generated_file_detection);
32455    }
32456
32457    #[test]
32458    fn generated_detection_false_when_disabled() {
32459        let mut form = blank_form();
32460        form.generated_file_detection = Some("disabled".to_string());
32461        assert!(!apply(&form).analysis.generated_file_detection);
32462    }
32463
32464    #[test]
32465    fn generated_detection_true_when_absent() {
32466        // None != Some("disabled") → true (safe default)
32467        assert!(
32468            apply(&blank_form()).analysis.generated_file_detection,
32469            "absent field must default to true (detection on)"
32470        );
32471    }
32472
32473    // ── minified_file_detection ──
32474
32475    #[test]
32476    fn minified_detection_false_when_disabled() {
32477        let mut form = blank_form();
32478        form.minified_file_detection = Some("disabled".to_string());
32479        assert!(!apply(&form).analysis.minified_file_detection);
32480    }
32481
32482    #[test]
32483    fn minified_detection_true_when_enabled() {
32484        let mut form = blank_form();
32485        form.minified_file_detection = Some("enabled".to_string());
32486        assert!(apply(&form).analysis.minified_file_detection);
32487    }
32488
32489    #[test]
32490    fn minified_detection_true_when_absent() {
32491        assert!(apply(&blank_form()).analysis.minified_file_detection);
32492    }
32493
32494    // ── vendor_directory_detection ──
32495
32496    #[test]
32497    fn vendor_detection_false_when_disabled() {
32498        let mut form = blank_form();
32499        form.vendor_directory_detection = Some("disabled".to_string());
32500        assert!(!apply(&form).analysis.vendor_directory_detection);
32501    }
32502
32503    #[test]
32504    fn vendor_detection_true_when_enabled() {
32505        let mut form = blank_form();
32506        form.vendor_directory_detection = Some("enabled".to_string());
32507        assert!(apply(&form).analysis.vendor_directory_detection);
32508    }
32509
32510    #[test]
32511    fn vendor_detection_true_when_absent() {
32512        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32513    }
32514
32515    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32516
32517    #[test]
32518    fn lockfiles_false_when_absent() {
32519        // None == Some("enabled") is false → lockfiles off (correct safe default)
32520        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32521    }
32522
32523    #[test]
32524    fn lockfiles_false_when_disabled() {
32525        let mut form = blank_form();
32526        form.include_lockfiles = Some("disabled".to_string());
32527        assert!(!apply(&form).analysis.include_lockfiles);
32528    }
32529
32530    #[test]
32531    fn lockfiles_true_when_enabled() {
32532        let mut form = blank_form();
32533        form.include_lockfiles = Some("enabled".to_string());
32534        assert!(apply(&form).analysis.include_lockfiles);
32535    }
32536
32537    // ── count_compiler_directives ──
32538
32539    #[test]
32540    fn compiler_directives_true_when_absent() {
32541        assert!(
32542            apply(&blank_form()).analysis.count_compiler_directives,
32543            "absent count_compiler_directives must default to true"
32544        );
32545    }
32546
32547    #[test]
32548    fn compiler_directives_true_when_enabled() {
32549        let mut form = blank_form();
32550        form.count_compiler_directives = Some("enabled".to_string());
32551        assert!(apply(&form).analysis.count_compiler_directives);
32552    }
32553
32554    #[test]
32555    fn compiler_directives_false_when_disabled() {
32556        let mut form = blank_form();
32557        form.count_compiler_directives = Some("disabled".to_string());
32558        assert!(!apply(&form).analysis.count_compiler_directives);
32559    }
32560
32561    // ── mixed_line_policy (enum select) ──
32562
32563    #[test]
32564    fn mixed_policy_unchanged_when_absent() {
32565        // None → if-let does nothing → stays at config default (CodeOnly)
32566        assert_eq!(
32567            apply(&blank_form()).analysis.mixed_line_policy,
32568            MixedLinePolicy::CodeOnly
32569        );
32570    }
32571
32572    #[test]
32573    fn mixed_policy_code_only() {
32574        let mut form = blank_form();
32575        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32576        assert_eq!(
32577            apply(&form).analysis.mixed_line_policy,
32578            MixedLinePolicy::CodeOnly
32579        );
32580    }
32581
32582    #[test]
32583    fn mixed_policy_code_and_comment() {
32584        let mut form = blank_form();
32585        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32586        assert_eq!(
32587            apply(&form).analysis.mixed_line_policy,
32588            MixedLinePolicy::CodeAndComment
32589        );
32590    }
32591
32592    #[test]
32593    fn mixed_policy_comment_only() {
32594        let mut form = blank_form();
32595        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32596        assert_eq!(
32597            apply(&form).analysis.mixed_line_policy,
32598            MixedLinePolicy::CommentOnly
32599        );
32600    }
32601
32602    #[test]
32603    fn mixed_policy_separate_mixed_category() {
32604        let mut form = blank_form();
32605        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32606        assert_eq!(
32607            apply(&form).analysis.mixed_line_policy,
32608            MixedLinePolicy::SeparateMixedCategory
32609        );
32610    }
32611
32612    // ── binary_file_behavior (enum select) ──
32613
32614    #[test]
32615    fn binary_behavior_skip_when_absent() {
32616        assert_eq!(
32617            apply(&blank_form()).analysis.binary_file_behavior,
32618            BinaryFileBehavior::Skip
32619        );
32620    }
32621
32622    #[test]
32623    fn binary_behavior_skip() {
32624        let mut form = blank_form();
32625        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32626        assert_eq!(
32627            apply(&form).analysis.binary_file_behavior,
32628            BinaryFileBehavior::Skip
32629        );
32630    }
32631
32632    #[test]
32633    fn binary_behavior_fail() {
32634        let mut form = blank_form();
32635        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32636        assert_eq!(
32637            apply(&form).analysis.binary_file_behavior,
32638            BinaryFileBehavior::Fail
32639        );
32640    }
32641
32642    // ── continuation_line_policy (enum select) ──
32643
32644    #[test]
32645    fn continuation_policy_each_physical_when_absent() {
32646        assert_eq!(
32647            apply(&blank_form()).analysis.continuation_line_policy,
32648            ContinuationLinePolicy::EachPhysicalLine
32649        );
32650    }
32651
32652    #[test]
32653    fn continuation_policy_collapse_to_logical() {
32654        let mut form = blank_form();
32655        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32656        assert_eq!(
32657            apply(&form).analysis.continuation_line_policy,
32658            ContinuationLinePolicy::CollapseToLogical
32659        );
32660    }
32661
32662    // ── blank_in_block_comment_policy (enum select) ──
32663
32664    #[test]
32665    fn blank_in_block_comment_count_as_comment_when_absent() {
32666        assert_eq!(
32667            apply(&blank_form()).analysis.blank_in_block_comment_policy,
32668            BlankInBlockCommentPolicy::CountAsComment
32669        );
32670    }
32671
32672    #[test]
32673    fn blank_in_block_comment_count_as_blank() {
32674        let mut form = blank_form();
32675        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
32676        assert_eq!(
32677            apply(&form).analysis.blank_in_block_comment_policy,
32678            BlankInBlockCommentPolicy::CountAsBlank
32679        );
32680    }
32681
32682    // ── style_col_threshold ──
32683
32684    #[test]
32685    fn style_threshold_80() {
32686        let mut form = blank_form();
32687        form.style_col_threshold = Some("80".to_string());
32688        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
32689    }
32690
32691    #[test]
32692    fn style_threshold_100() {
32693        let mut form = blank_form();
32694        form.style_col_threshold = Some("100".to_string());
32695        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
32696    }
32697
32698    #[test]
32699    fn style_threshold_120() {
32700        let mut form = blank_form();
32701        form.style_col_threshold = Some("120".to_string());
32702        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
32703    }
32704
32705    #[test]
32706    fn style_threshold_invalid_value_leaves_default() {
32707        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
32708        let mut cfg = sloc_config::AppConfig::default();
32709        let mut form = blank_form();
32710        form.style_col_threshold = Some("42".to_string());
32711        apply_form_to_config(&mut cfg, &form);
32712        assert_eq!(
32713            cfg.analysis.style_col_threshold, 80,
32714            "invalid threshold must not change config"
32715        );
32716    }
32717
32718    #[test]
32719    fn style_threshold_non_numeric_leaves_default() {
32720        let mut cfg = sloc_config::AppConfig::default();
32721        let mut form = blank_form();
32722        form.style_col_threshold = Some("large".to_string());
32723        apply_form_to_config(&mut cfg, &form);
32724        assert_eq!(cfg.analysis.style_col_threshold, 80);
32725    }
32726
32727    #[test]
32728    fn style_threshold_zero_leaves_default() {
32729        let mut cfg = sloc_config::AppConfig::default();
32730        let mut form = blank_form();
32731        form.style_col_threshold = Some("0".to_string());
32732        apply_form_to_config(&mut cfg, &form);
32733        assert_eq!(cfg.analysis.style_col_threshold, 80);
32734    }
32735
32736    #[test]
32737    fn style_threshold_absent_leaves_default() {
32738        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
32739    }
32740
32741    // ── style_score_threshold ──
32742
32743    #[test]
32744    fn style_score_threshold_zero_when_absent() {
32745        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
32746    }
32747
32748    #[test]
32749    fn style_score_threshold_set_to_valid_value() {
32750        let mut form = blank_form();
32751        form.style_score_threshold = Some("70".to_string());
32752        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
32753    }
32754
32755    #[test]
32756    fn style_score_threshold_clamps_to_100_when_over() {
32757        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
32758        let mut form = blank_form();
32759        form.style_score_threshold = Some("200".to_string());
32760        assert_eq!(
32761            apply(&form).analysis.style_score_threshold,
32762            100,
32763            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
32764        );
32765    }
32766
32767    // ── coverage_file ──
32768
32769    #[test]
32770    fn coverage_file_none_when_absent() {
32771        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
32772    }
32773
32774    #[test]
32775    fn coverage_file_none_when_whitespace_only() {
32776        let mut form = blank_form();
32777        form.coverage_file = Some("   ".to_string());
32778        assert!(
32779            apply(&form).analysis.coverage_file.is_none(),
32780            "whitespace-only coverage_file must be treated as None"
32781        );
32782    }
32783
32784    #[test]
32785    fn coverage_file_set_when_non_empty() {
32786        let mut form = blank_form();
32787        form.coverage_file = Some("coverage/lcov.info".to_string());
32788        assert_eq!(
32789            apply(&form).analysis.coverage_file,
32790            Some(std::path::PathBuf::from("coverage/lcov.info"))
32791        );
32792    }
32793
32794    #[test]
32795    fn coverage_file_trims_whitespace() {
32796        let mut form = blank_form();
32797        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
32798        assert_eq!(
32799            apply(&form).analysis.coverage_file,
32800            Some(std::path::PathBuf::from("coverage/lcov.info"))
32801        );
32802    }
32803
32804    // ── report_title ──
32805
32806    #[test]
32807    fn report_title_unchanged_when_absent() {
32808        let original = sloc_config::AppConfig::default().reporting.report_title;
32809        assert_eq!(apply(&blank_form()).reporting.report_title, original);
32810    }
32811
32812    #[test]
32813    fn report_title_unchanged_when_whitespace_only() {
32814        let original = sloc_config::AppConfig::default().reporting.report_title;
32815        let mut form = blank_form();
32816        form.report_title = Some("   ".to_string());
32817        assert_eq!(
32818            apply(&form).reporting.report_title,
32819            original,
32820            "whitespace-only title must not overwrite the default"
32821        );
32822    }
32823
32824    #[test]
32825    fn report_title_updated_and_trimmed() {
32826        let mut form = blank_form();
32827        form.report_title = Some("  My Project  ".to_string());
32828        assert_eq!(apply(&form).reporting.report_title, "My Project");
32829    }
32830
32831    // ── report_header_footer ──
32832
32833    #[test]
32834    fn header_footer_none_when_absent() {
32835        assert!(apply(&blank_form())
32836            .reporting
32837            .report_header_footer
32838            .is_none());
32839    }
32840
32841    #[test]
32842    fn header_footer_none_when_whitespace_only() {
32843        let mut form = blank_form();
32844        form.report_header_footer = Some("  ".to_string());
32845        assert!(apply(&form).reporting.report_header_footer.is_none());
32846    }
32847
32848    #[test]
32849    fn header_footer_set_and_trimmed() {
32850        let mut form = blank_form();
32851        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
32852        assert_eq!(
32853            apply(&form).reporting.report_header_footer,
32854            Some("Confidential — Internal Use".to_string())
32855        );
32856    }
32857
32858    // ── include_globs / exclude_globs ──
32859
32860    #[test]
32861    fn include_globs_empty_when_absent() {
32862        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
32863    }
32864
32865    #[test]
32866    fn include_globs_newline_separated() {
32867        let mut form = blank_form();
32868        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
32869        assert_eq!(
32870            apply(&form).discovery.include_globs,
32871            vec!["src/**/*.rs", "tests/**/*.rs"]
32872        );
32873    }
32874
32875    #[test]
32876    fn exclude_globs_comma_separated() {
32877        let mut form = blank_form();
32878        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
32879        assert_eq!(
32880            apply(&form).discovery.exclude_globs,
32881            vec!["vendor/**", "node_modules/**"]
32882        );
32883    }
32884
32885    #[test]
32886    fn globs_mixed_separators() {
32887        let mut form = blank_form();
32888        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
32889        assert_eq!(
32890            apply(&form).discovery.exclude_globs,
32891            vec!["a/**", "b/**", "c/**"]
32892        );
32893    }
32894
32895    // ── split_patterns unit tests ──
32896
32897    #[test]
32898    fn split_patterns_none_is_empty() {
32899        assert!(split_patterns(None).is_empty());
32900    }
32901
32902    #[test]
32903    fn split_patterns_empty_string_is_empty() {
32904        assert!(split_patterns(Some("")).is_empty());
32905    }
32906
32907    #[test]
32908    fn split_patterns_whitespace_only_is_empty() {
32909        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
32910    }
32911
32912    #[test]
32913    fn split_patterns_newlines() {
32914        assert_eq!(
32915            split_patterns(Some("a/**\nb/**\nc/**")),
32916            vec!["a/**", "b/**", "c/**"]
32917        );
32918    }
32919
32920    #[test]
32921    fn split_patterns_commas() {
32922        assert_eq!(
32923            split_patterns(Some("a/**,b/**,c/**")),
32924            vec!["a/**", "b/**", "c/**"]
32925        );
32926    }
32927
32928    #[test]
32929    fn split_patterns_mixed() {
32930        assert_eq!(
32931            split_patterns(Some("a/**\nb/**,c/**")),
32932            vec!["a/**", "b/**", "c/**"]
32933        );
32934    }
32935
32936    #[test]
32937    fn split_patterns_trims_whitespace() {
32938        assert_eq!(
32939            split_patterns(Some("  a/**  \n  b/**  ")),
32940            vec!["a/**", "b/**"]
32941        );
32942    }
32943
32944    #[test]
32945    fn split_patterns_filters_empty_entries() {
32946        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
32947    }
32948
32949    #[test]
32950    fn split_patterns_single_entry() {
32951        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
32952    }
32953}
32954
32955#[cfg(test)]
32956mod utility_tests {
32957    use super::*;
32958    use std::net::IpAddr;
32959    use std::time::Duration;
32960
32961    // ── sanitize_project_label ────────────────────────────────────────────────
32962
32963    #[test]
32964    fn sanitize_simple_name() {
32965        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
32966    }
32967
32968    #[test]
32969    fn sanitize_uppercased_lowercased() {
32970        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
32971    }
32972
32973    #[test]
32974    fn sanitize_path_extracts_filename() {
32975        assert_eq!(
32976            sanitize_project_label("/home/user/my-project"),
32977            "my-project"
32978        );
32979    }
32980
32981    #[test]
32982    fn sanitize_path_uses_last_component() {
32983        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
32984    }
32985
32986    #[test]
32987    fn sanitize_spaces_become_hyphens() {
32988        assert_eq!(sanitize_project_label("my project"), "my-project");
32989    }
32990
32991    #[test]
32992    fn sanitize_non_ascii_become_hyphens() {
32993        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
32994    }
32995
32996    #[test]
32997    fn sanitize_all_special_chars_gives_project() {
32998        assert_eq!(sanitize_project_label("!@#$%^"), "project");
32999    }
33000
33001    #[test]
33002    fn sanitize_empty_string_gives_project() {
33003        assert_eq!(sanitize_project_label(""), "project");
33004    }
33005
33006    #[test]
33007    fn sanitize_leading_trailing_hyphens_stripped() {
33008        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33009    }
33010
33011    #[test]
33012    fn sanitize_alphanumeric_preserved() {
33013        assert_eq!(sanitize_project_label("repo123"), "repo123");
33014    }
33015
33016    #[test]
33017    fn sanitize_dots_become_hyphens() {
33018        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33019    }
33020
33021    #[test]
33022    fn sanitize_mixed_slashes_uses_filename() {
33023        // The Windows path separator — on all platforms Path::file_name still works
33024        assert_eq!(sanitize_project_label("project-name"), "project-name");
33025    }
33026
33027    // ── IpRateLimiter ─────────────────────────────────────────────────────────
33028
33029    #[test]
33030    fn rate_limiter_allows_first_request() {
33031        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33032        let ip: IpAddr = "127.0.0.1".parse().unwrap();
33033        assert!(rl.is_allowed(ip));
33034    }
33035
33036    #[test]
33037    fn rate_limiter_blocks_after_limit_reached() {
33038        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33039        let ip: IpAddr = "10.0.0.1".parse().unwrap();
33040        assert!(rl.is_allowed(ip));
33041        assert!(rl.is_allowed(ip));
33042        assert!(rl.is_allowed(ip));
33043        assert!(!rl.is_allowed(ip), "4th request must be blocked");
33044    }
33045
33046    #[test]
33047    fn rate_limiter_allows_requests_up_to_limit() {
33048        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33049        let ip: IpAddr = "10.0.0.2".parse().unwrap();
33050        for _ in 0..5 {
33051            assert!(rl.is_allowed(ip));
33052        }
33053        assert!(!rl.is_allowed(ip), "6th request must be blocked");
33054    }
33055
33056    #[test]
33057    fn rate_limiter_different_ips_are_independent() {
33058        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33059        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33060        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33061        assert!(rl.is_allowed(ip1));
33062        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33063        assert!(rl.is_allowed(ip2), "ip2 must be independent");
33064    }
33065
33066    #[test]
33067    fn rate_limiter_auth_failure_not_locked_below_threshold() {
33068        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33069        let ip: IpAddr = "10.0.0.3".parse().unwrap();
33070        rl.record_auth_failure(ip);
33071        rl.record_auth_failure(ip);
33072        assert!(
33073            !rl.is_auth_locked_out(ip),
33074            "not locked at 2 failures when threshold is 3"
33075        );
33076    }
33077
33078    #[test]
33079    fn rate_limiter_auth_failure_locked_at_threshold() {
33080        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33081        let ip: IpAddr = "10.0.0.4".parse().unwrap();
33082        rl.record_auth_failure(ip);
33083        rl.record_auth_failure(ip);
33084        rl.record_auth_failure(ip);
33085        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33086    }
33087
33088    #[test]
33089    fn rate_limiter_auth_failure_different_ips_independent() {
33090        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33091        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33092        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33093        rl.record_auth_failure(ip1);
33094        rl.record_auth_failure(ip1);
33095        assert!(rl.is_auth_locked_out(ip1));
33096        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33097    }
33098
33099    #[test]
33100    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33101        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33102        let ip: IpAddr = "127.0.0.2".parse().unwrap();
33103        for _ in 0..100 {
33104            assert!(rl.is_allowed(ip));
33105        }
33106    }
33107
33108    // ── strip_unc_prefix ──────────────────────────────────────────────────────
33109
33110    #[test]
33111    fn strip_unc_plain_path_unchanged() {
33112        let p = PathBuf::from("C:\\Users\\user\\project");
33113        let result = strip_unc_prefix(p.clone());
33114        assert_eq!(result, p);
33115    }
33116
33117    #[test]
33118    fn strip_unc_with_drive_prefix_stripped() {
33119        let p = PathBuf::from(r"\\?\C:\Users\user\project");
33120        let result = strip_unc_prefix(p);
33121        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33122    }
33123
33124    #[test]
33125    fn strip_unc_with_network_prefix_stripped() {
33126        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33127        let result = strip_unc_prefix(p);
33128        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33129    }
33130
33131    #[test]
33132    fn strip_unc_linux_path_unchanged() {
33133        let p = PathBuf::from("/home/user/project");
33134        let result = strip_unc_prefix(p.clone());
33135        assert_eq!(result, p);
33136    }
33137
33138    // ── remote_to_commit_url ──────────────────────────────────────────────────
33139
33140    #[test]
33141    fn remote_to_commit_url_github_https() {
33142        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33143        assert_eq!(
33144            url,
33145            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33146        );
33147    }
33148
33149    #[test]
33150    fn remote_to_commit_url_github_ssh() {
33151        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33152        assert_eq!(
33153            url,
33154            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33155        );
33156    }
33157
33158    #[test]
33159    fn remote_to_commit_url_gitlab_uses_dash_commit() {
33160        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33161        assert_eq!(
33162            url,
33163            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33164        );
33165    }
33166
33167    #[test]
33168    fn remote_to_commit_url_bitbucket_uses_commits() {
33169        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33170        assert_eq!(
33171            url,
33172            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33173        );
33174    }
33175
33176    #[test]
33177    fn remote_to_commit_url_unknown_scheme_returns_none() {
33178        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33179        assert!(url.is_none());
33180    }
33181
33182    #[test]
33183    fn remote_to_commit_url_ssh_gitlab() {
33184        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33185        assert!(url.is_some());
33186        let u = url.unwrap();
33187        assert!(
33188            u.contains("/-/commit/sha123"),
33189            "gitlab ssh must use /-/commit/"
33190        );
33191    }
33192
33193    // ── git_clone_dest ────────────────────────────────────────────────────────
33194
33195    #[test]
33196    fn git_clone_dest_github_url_produces_safe_name() {
33197        let dir = PathBuf::from("/tmp/clones");
33198        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33199        let name = dest.file_name().unwrap().to_string_lossy();
33200        assert!(!name.is_empty());
33201        assert!(
33202            name.chars()
33203                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33204            "clone dest must only contain safe chars, got: {name}"
33205        );
33206    }
33207
33208    #[test]
33209    fn git_clone_dest_is_inside_clones_dir() {
33210        let dir = PathBuf::from("/tmp/clones");
33211        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33212        assert!(
33213            dest.starts_with(&dir),
33214            "clone dest must be inside clones_dir"
33215        );
33216    }
33217
33218    #[test]
33219    fn git_clone_dest_truncates_to_80_chars_max() {
33220        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33221        let dir = PathBuf::from("/tmp/clones");
33222        let dest = git_clone_dest(&long_url, &dir);
33223        let name = dest.file_name().unwrap().to_string_lossy();
33224        assert!(
33225            name.len() <= 80,
33226            "clone dest name must be at most 80 chars, got {} chars: {name}",
33227            name.len()
33228        );
33229    }
33230
33231    #[test]
33232    fn git_clone_dest_special_chars_replaced_with_underscore() {
33233        let dir = PathBuf::from("/tmp/clones");
33234        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33235        let name = dest.file_name().unwrap().to_string_lossy();
33236        assert!(
33237            !name.contains('@') && !name.contains(':') && !name.contains('/'),
33238            "special chars must be replaced in clone dest, got: {name}"
33239        );
33240    }
33241
33242    #[test]
33243    fn git_clone_dest_different_urls_differ() {
33244        let dir = PathBuf::from("/tmp/clones");
33245        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33246        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33247        assert_ne!(
33248            a, b,
33249            "different repos must produce different clone dest names"
33250        );
33251    }
33252
33253    #[test]
33254    fn git_clone_dest_same_url_same_result() {
33255        let dir = PathBuf::from("/tmp/clones");
33256        let url = "https://github.com/owner/repo.git";
33257        assert_eq!(
33258            git_clone_dest(url, &dir),
33259            git_clone_dest(url, &dir),
33260            "same URL must always give same clone dest"
33261        );
33262    }
33263
33264    // ── fmt_delta ─────────────────────────────────────────────────────────────
33265
33266    #[test]
33267    fn fmt_delta_positive_has_plus_prefix() {
33268        assert_eq!(fmt_delta(5), "+5");
33269    }
33270
33271    #[test]
33272    fn fmt_delta_negative_no_plus_prefix() {
33273        assert_eq!(fmt_delta(-3), "-3");
33274    }
33275
33276    #[test]
33277    fn fmt_delta_zero() {
33278        assert_eq!(fmt_delta(0), "0");
33279    }
33280
33281    // ── delta_class ───────────────────────────────────────────────────────────
33282
33283    #[test]
33284    fn delta_class_positive_is_pos() {
33285        assert_eq!(delta_class(1), "pos");
33286    }
33287
33288    #[test]
33289    fn delta_class_negative_is_neg() {
33290        assert_eq!(delta_class(-1), "neg");
33291    }
33292
33293    #[test]
33294    fn delta_class_zero_is_zero_class() {
33295        assert_eq!(delta_class(0), "zero");
33296    }
33297
33298    // ── fmt_pct ───────────────────────────────────────────────────────────────
33299
33300    #[test]
33301    fn fmt_pct_zero_baseline_returns_em_dash() {
33302        assert_eq!(fmt_pct(100, 0), "\u{2014}");
33303    }
33304
33305    #[test]
33306    fn fmt_pct_positive_delta_has_plus_sign() {
33307        let result = fmt_pct(10, 100);
33308        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33309    }
33310
33311    #[test]
33312    fn fmt_pct_negative_delta_no_plus_sign() {
33313        let result = fmt_pct(-10, 100);
33314        assert!(!result.starts_with('+'), "unexpected + in: {result}");
33315        assert!(result.contains('%'));
33316    }
33317
33318    #[test]
33319    fn fmt_pct_near_zero_returns_pm_zero() {
33320        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33321    }
33322
33323    // ── summary_delta ─────────────────────────────────────────────────────────
33324
33325    #[test]
33326    fn summary_delta_no_prev_returns_dash_na() {
33327        let (display, class) = summary_delta(10, None);
33328        assert_eq!(display, "\u{2014}");
33329        assert_eq!(class, "na");
33330    }
33331
33332    #[test]
33333    fn summary_delta_increase_is_positive() {
33334        let (display, class) = summary_delta(15, Some(10));
33335        assert_eq!(display, "+5");
33336        assert_eq!(class, "pos");
33337    }
33338
33339    #[test]
33340    fn summary_delta_decrease_is_negative() {
33341        let (display, class) = summary_delta(5, Some(10));
33342        assert_eq!(display, "-5");
33343        assert_eq!(class, "neg");
33344    }
33345
33346    // ── nth_weekday_of_month ──────────────────────────────────────────────────
33347
33348    #[test]
33349    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33350        use chrono::Datelike;
33351        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33352        assert_eq!(d.year(), 2024);
33353        assert_eq!(d.month(), 1);
33354        assert_eq!(d.weekday(), chrono::Weekday::Mon);
33355        assert!(d.day() <= 7);
33356    }
33357
33358    #[test]
33359    fn nth_weekday_second_sunday_march_2024_is_10th() {
33360        use chrono::Datelike;
33361        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33362        assert_eq!(d.weekday(), chrono::Weekday::Sun);
33363        assert_eq!(d.month(), 3);
33364        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33365    }
33366
33367    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
33368
33369    #[test]
33370    fn is_pacific_dst_july_is_true() {
33371        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33372        assert!(is_pacific_dst(dt), "July must be PDT");
33373    }
33374
33375    #[test]
33376    fn is_pacific_dst_january_is_false() {
33377        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33378        assert!(!is_pacific_dst(dt), "January must be PST");
33379    }
33380
33381    #[test]
33382    fn fmt_la_time_summer_shows_pdt() {
33383        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33384        let result = fmt_la_time(dt);
33385        assert!(
33386            result.ends_with("PDT"),
33387            "summer must use PDT, got: {result}"
33388        );
33389    }
33390
33391    #[test]
33392    fn fmt_la_time_winter_shows_pst() {
33393        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33394        let result = fmt_la_time(dt);
33395        assert!(
33396            result.ends_with("PST"),
33397            "winter must use PST, got: {result}"
33398        );
33399    }
33400
33401    #[test]
33402    fn fmt_la_time_meta_summer_shows_pdt() {
33403        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33404        let result = fmt_la_time_meta(dt);
33405        assert!(
33406            result.ends_with("PDT"),
33407            "meta summer must use PDT, got: {result}"
33408        );
33409    }
33410
33411    #[test]
33412    fn fmt_la_time_meta_winter_shows_pst() {
33413        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33414        let result = fmt_la_time_meta(dt);
33415        assert!(
33416            result.ends_with("PST"),
33417            "meta winter must use PST, got: {result}"
33418        );
33419    }
33420
33421    // ── fmt_git_date ──────────────────────────────────────────────────────────
33422
33423    #[test]
33424    fn fmt_git_date_valid_iso_returns_some() {
33425        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33426    }
33427
33428    #[test]
33429    fn fmt_git_date_invalid_returns_none() {
33430        assert!(fmt_git_date("not-a-date").is_none());
33431    }
33432
33433    // ── format_number ─────────────────────────────────────────────────────────
33434
33435    #[test]
33436    fn format_number_zero() {
33437        assert_eq!(format_number(0), "0");
33438    }
33439
33440    #[test]
33441    fn format_number_three_digits_no_comma() {
33442        assert_eq!(format_number(999), "999");
33443    }
33444
33445    #[test]
33446    fn format_number_four_digits_has_comma() {
33447        assert_eq!(format_number(1000), "1,000");
33448    }
33449
33450    #[test]
33451    fn format_number_seven_digits_two_commas() {
33452        assert_eq!(format_number(1_234_567), "1,234,567");
33453    }
33454
33455    #[test]
33456    fn format_number_one_million() {
33457        assert_eq!(format_number(1_000_000), "1,000,000");
33458    }
33459
33460    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
33461
33462    #[test]
33463    fn badge_text_px_empty_is_zero() {
33464        assert_eq!(badge_text_px(""), 0);
33465    }
33466
33467    #[test]
33468    fn badge_text_px_narrow_chars_smaller_than_normal() {
33469        assert!(
33470            badge_text_px("if") < badge_text_px("ab"),
33471            "'if' must be narrower than 'ab'"
33472        );
33473    }
33474
33475    #[test]
33476    fn badge_text_px_m_is_wider_than_a() {
33477        assert!(
33478            badge_text_px("m") > badge_text_px("a"),
33479            "'m' must be wider than 'a'"
33480        );
33481    }
33482
33483    #[test]
33484    fn render_badge_svg_contains_label_and_value() {
33485        let svg = render_badge_svg("coverage", "95%", "#4c1");
33486        assert!(svg.contains("coverage") && svg.contains("95%"));
33487    }
33488
33489    #[test]
33490    fn render_badge_svg_contains_color() {
33491        let svg = render_badge_svg("sloc", "12K", "#e05d44");
33492        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33493    }
33494
33495    #[test]
33496    fn render_badge_svg_escapes_ampersand_in_label() {
33497        let svg = render_badge_svg("test&label", "ok", "#4c1");
33498        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33499    }
33500
33501    // ── build_pdf_filename ────────────────────────────────────────────────────
33502
33503    #[test]
33504    fn build_pdf_filename_slugifies_title() {
33505        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33506        assert!(
33507            name.starts_with("my_project_report_")
33508                && std::path::Path::new(&name)
33509                    .extension()
33510                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33511        );
33512    }
33513
33514    #[test]
33515    fn build_pdf_filename_uses_last_run_id_segment() {
33516        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33517        assert!(name.contains("ABCD"), "must use last segment of run_id");
33518    }
33519
33520    #[test]
33521    fn build_pdf_filename_empty_title_uses_report_prefix() {
33522        let name = build_pdf_filename("", "abc-def-9999");
33523        assert!(
33524            name.starts_with("report_")
33525                && std::path::Path::new(&name)
33526                    .extension()
33527                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33528        );
33529    }
33530
33531    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33532
33533    #[test]
33534    fn swap_chart_js_replaces_inline_block() {
33535        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33536        let result = swap_inline_chart_js_for_static(html.to_string());
33537        assert!(result.contains(r#"src="/static/chart-report.js""#));
33538        assert!(!result.contains("inline source"));
33539    }
33540
33541    #[test]
33542    fn swap_chart_js_no_head_returns_unchanged() {
33543        let html = "<body>no head here</body>";
33544        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33545    }
33546
33547    #[test]
33548    fn swap_chart_js_no_script_in_head_unchanged() {
33549        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33550        let result = swap_inline_chart_js_for_static(html.to_string());
33551        assert!(!result.contains("chart-report.js"));
33552    }
33553
33554    // ── patch_html_nonce ──────────────────────────────────────────────────────
33555
33556    #[test]
33557    fn patch_html_nonce_replaces_old_nonce() {
33558        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33559        let result = patch_html_nonce(html, "new-nonce-456");
33560        assert!(result.contains(r#"nonce="new-nonce-456""#));
33561        assert!(!result.contains("old-nonce-123"));
33562    }
33563
33564    #[test]
33565    fn patch_html_nonce_injects_into_bare_style() {
33566        let html = "<style>body{color:red;}</style>";
33567        let result = patch_html_nonce(html, "fresh-nonce");
33568        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33569    }
33570
33571    #[test]
33572    fn patch_html_nonce_injects_into_bare_script() {
33573        let html = "<script>console.log(1);</script>";
33574        let result = patch_html_nonce(html, "abc");
33575        assert!(result.contains(r#"<script nonce="abc">"#));
33576    }
33577
33578    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
33579
33580    #[test]
33581    fn is_html_report_file_result_html_matches() {
33582        let dir = tempfile::tempdir().unwrap();
33583        let path = dir.path().join("result_20240101.html");
33584        std::fs::write(&path, b"<html></html>").unwrap();
33585        assert!(is_html_report_file(&path));
33586    }
33587
33588    #[test]
33589    fn is_html_report_file_report_html_matches() {
33590        let dir = tempfile::tempdir().unwrap();
33591        let path = dir.path().join("report_abc.html");
33592        std::fs::write(&path, b"<html></html>").unwrap();
33593        assert!(is_html_report_file(&path));
33594    }
33595
33596    #[test]
33597    fn is_html_report_file_index_html_does_not_match() {
33598        let dir = tempfile::tempdir().unwrap();
33599        let path = dir.path().join("index.html");
33600        std::fs::write(&path, b"<html></html>").unwrap();
33601        assert!(!is_html_report_file(&path));
33602    }
33603
33604    #[test]
33605    fn is_html_report_file_nonexistent_returns_false() {
33606        assert!(!is_html_report_file(Path::new(
33607            "/nonexistent/result_xyz.html"
33608        )));
33609    }
33610
33611    #[test]
33612    fn find_html_report_in_dir_finds_result_html() {
33613        let dir = tempfile::tempdir().unwrap();
33614        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33615        assert!(find_html_report_in_dir(dir.path()).is_some());
33616    }
33617
33618    #[test]
33619    fn find_html_report_in_dir_empty_returns_none() {
33620        let dir = tempfile::tempdir().unwrap();
33621        assert!(find_html_report_in_dir(dir.path()).is_none());
33622    }
33623
33624    #[test]
33625    fn find_html_report_in_tree_finds_in_subdir() {
33626        let dir = tempfile::tempdir().unwrap();
33627        let subdir = dir.path().join("run-001");
33628        std::fs::create_dir_all(&subdir).unwrap();
33629        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33630        assert!(find_html_report_in_tree(dir.path()).is_some());
33631    }
33632
33633    // ── derive_project_label ──────────────────────────────────────────────────
33634
33635    #[test]
33636    fn derive_project_label_with_git_repo_and_ref() {
33637        let label = derive_project_label(
33638            Some("https://github.com/owner/my-repo.git"),
33639            Some("main"),
33640            "/fallback/path",
33641        );
33642        assert!(!label.is_empty(), "label must not be empty");
33643        assert!(
33644            label.contains("my") || label.contains("repo"),
33645            "got: {label}"
33646        );
33647    }
33648
33649    #[test]
33650    fn derive_project_label_fallback_to_path() {
33651        let label = derive_project_label(None, None, "/path/to/myproject");
33652        assert_eq!(label, "myproject");
33653    }
33654
33655    #[test]
33656    fn derive_project_label_empty_git_fields_use_path() {
33657        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33658        assert_eq!(label, "cool-app");
33659    }
33660
33661    // ── derive_file_stem ──────────────────────────────────────────────────────
33662
33663    #[test]
33664    fn derive_file_stem_with_commit_appends_sha() {
33665        assert_eq!(
33666            derive_file_stem("myproject", Some("a1b2c3")),
33667            "myproject_a1b2c3"
33668        );
33669    }
33670
33671    #[test]
33672    fn derive_file_stem_without_commit_returns_label() {
33673        assert_eq!(derive_file_stem("myproject", None), "myproject");
33674    }
33675
33676    #[test]
33677    fn derive_file_stem_empty_commit_returns_label() {
33678        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
33679    }
33680
33681    // ── split_patterns ────────────────────────────────────────────────────────
33682
33683    #[test]
33684    fn split_patterns_none_is_empty() {
33685        assert!(split_patterns(None).is_empty());
33686    }
33687
33688    #[test]
33689    fn split_patterns_empty_string_is_empty() {
33690        assert!(split_patterns(Some("")).is_empty());
33691    }
33692
33693    #[test]
33694    fn split_patterns_comma_separated() {
33695        assert_eq!(
33696            split_patterns(Some("foo,bar,baz")),
33697            vec!["foo", "bar", "baz"]
33698        );
33699    }
33700
33701    #[test]
33702    fn split_patterns_newline_separated() {
33703        assert_eq!(
33704            split_patterns(Some("foo\nbar\nbaz")),
33705            vec!["foo", "bar", "baz"]
33706        );
33707    }
33708
33709    #[test]
33710    fn split_patterns_trims_whitespace() {
33711        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
33712    }
33713
33714    // ── make_git_label ────────────────────────────────────────────────────────
33715
33716    #[test]
33717    fn make_git_label_empty_repo_empty_result() {
33718        assert_eq!(make_git_label("", "main"), "");
33719    }
33720
33721    #[test]
33722    fn make_git_label_empty_ref_empty_result() {
33723        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
33724    }
33725
33726    #[test]
33727    fn make_git_label_basic_format() {
33728        assert_eq!(
33729            make_git_label("https://github.com/owner/my-repo.git", "main"),
33730            "my-repo_at_main_sloc"
33731        );
33732    }
33733
33734    #[test]
33735    fn make_git_label_slash_in_ref_replaced() {
33736        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
33737        assert!(
33738            !label.contains('/'),
33739            "slash in ref must be replaced: {label}"
33740        );
33741    }
33742
33743    // ── format_dir_size ───────────────────────────────────────────────────────
33744
33745    #[test]
33746    fn format_dir_size_bytes() {
33747        assert_eq!(format_dir_size(500), "500 B");
33748    }
33749
33750    #[test]
33751    fn format_dir_size_kilobytes() {
33752        assert_eq!(format_dir_size(2048), "2 KB");
33753    }
33754
33755    #[test]
33756    fn format_dir_size_megabytes() {
33757        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
33758    }
33759
33760    #[test]
33761    fn format_dir_size_gigabytes() {
33762        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
33763    }
33764
33765    #[test]
33766    fn format_dir_size_zero() {
33767        assert_eq!(format_dir_size(0), "0 B");
33768    }
33769
33770    // ── civil_from_days ───────────────────────────────────────────────────────
33771
33772    #[test]
33773    fn civil_from_days_epoch() {
33774        assert_eq!(civil_from_days(0), (1970, 1, 1));
33775    }
33776
33777    #[test]
33778    fn civil_from_days_one_year_later() {
33779        assert_eq!(civil_from_days(365), (1971, 1, 1));
33780    }
33781
33782    #[test]
33783    fn civil_from_days_31_days_is_feb_1_1970() {
33784        assert_eq!(civil_from_days(31), (1970, 2, 1));
33785    }
33786
33787    // ── format_system_time ────────────────────────────────────────────────────
33788
33789    #[test]
33790    fn format_system_time_unix_epoch_formats_correctly() {
33791        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
33792    }
33793
33794    #[test]
33795    fn format_system_time_31_days_after_epoch() {
33796        let t = UNIX_EPOCH + Duration::from_hours(744);
33797        assert_eq!(format_system_time(t), "1970-02-01 00:00");
33798    }
33799
33800    #[test]
33801    fn format_system_time_before_epoch_returns_dash() {
33802        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
33803            assert_eq!(format_system_time(before), "-");
33804        }
33805    }
33806
33807    // ── detect_language_name ──────────────────────────────────────────────────
33808
33809    #[test]
33810    fn detect_language_name_dot_c() {
33811        assert_eq!(detect_language_name("main.c"), Some("C"));
33812    }
33813
33814    #[test]
33815    fn detect_language_name_dot_h() {
33816        assert_eq!(detect_language_name("defs.h"), Some("C"));
33817    }
33818
33819    #[test]
33820    fn detect_language_name_dot_cpp() {
33821        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
33822    }
33823
33824    #[test]
33825    fn detect_language_name_dot_py() {
33826        assert_eq!(detect_language_name("script.py"), Some("Python"));
33827    }
33828
33829    #[test]
33830    fn detect_language_name_dot_ps1() {
33831        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
33832    }
33833
33834    #[test]
33835    fn detect_language_name_dot_cs() {
33836        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
33837    }
33838
33839    #[test]
33840    fn detect_language_name_dot_sh() {
33841        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
33842    }
33843
33844    #[test]
33845    fn detect_language_name_unknown_txt() {
33846        assert_eq!(detect_language_name("notes.txt"), None);
33847    }
33848
33849    // ── language_icon_file ────────────────────────────────────────────────────
33850
33851    #[test]
33852    fn language_icon_file_c() {
33853        assert_eq!(language_icon_file("C"), Some("c.png"));
33854    }
33855
33856    #[test]
33857    fn language_icon_file_python() {
33858        assert_eq!(language_icon_file("Python"), Some("python.png"));
33859    }
33860
33861    #[test]
33862    fn language_icon_file_dockerfile() {
33863        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
33864    }
33865
33866    #[test]
33867    fn language_icon_file_rust_is_none() {
33868        assert!(language_icon_file("Rust").is_none());
33869    }
33870
33871    #[test]
33872    fn language_icon_file_unknown_is_none() {
33873        assert!(language_icon_file("Fortran").is_none());
33874    }
33875
33876    // ── language_inline_svg ───────────────────────────────────────────────────
33877
33878    #[test]
33879    fn language_inline_svg_rust_is_svg() {
33880        let svg = language_inline_svg("Rust").unwrap();
33881        assert!(svg.starts_with("<svg"));
33882    }
33883
33884    #[test]
33885    fn language_inline_svg_typescript_is_some() {
33886        assert!(language_inline_svg("TypeScript").is_some());
33887    }
33888
33889    #[test]
33890    fn language_inline_svg_unknown_is_none() {
33891        assert!(language_inline_svg("Fortran").is_none());
33892    }
33893
33894    // ── classify_preview_file ─────────────────────────────────────────────────
33895
33896    #[test]
33897    fn classify_preview_file_c_supported() {
33898        assert!(matches!(
33899            classify_preview_file("main.c"),
33900            PreviewKind::Supported
33901        ));
33902    }
33903
33904    #[test]
33905    fn classify_preview_file_python_supported() {
33906        assert!(matches!(
33907            classify_preview_file("script.py"),
33908            PreviewKind::Supported
33909        ));
33910    }
33911
33912    #[test]
33913    fn classify_preview_file_png_skipped() {
33914        assert!(matches!(
33915            classify_preview_file("image.png"),
33916            PreviewKind::Skipped
33917        ));
33918    }
33919
33920    #[test]
33921    fn classify_preview_file_zip_skipped() {
33922        assert!(matches!(
33923            classify_preview_file("archive.zip"),
33924            PreviewKind::Skipped
33925        ));
33926    }
33927
33928    #[test]
33929    fn classify_preview_file_min_js_skipped() {
33930        assert!(matches!(
33931            classify_preview_file("bundle.min.js"),
33932            PreviewKind::Skipped
33933        ));
33934    }
33935
33936    #[test]
33937    fn classify_preview_file_rs_unsupported() {
33938        assert!(matches!(
33939            classify_preview_file("main.rs"),
33940            PreviewKind::Unsupported
33941        ));
33942    }
33943
33944    // ── preview_relative_path ─────────────────────────────────────────────────
33945
33946    #[test]
33947    fn preview_relative_path_strips_root() {
33948        let root = PathBuf::from("/project");
33949        let path = PathBuf::from("/project/src/main.c");
33950        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
33951    }
33952
33953    #[test]
33954    fn preview_relative_path_unrooted_includes_filename() {
33955        let root = PathBuf::from("/other");
33956        let path = PathBuf::from("/project/src/main.c");
33957        let result = preview_relative_path(&root, &path);
33958        assert!(result.contains("main.c"));
33959    }
33960
33961    #[test]
33962    fn preview_relative_path_uses_forward_slashes() {
33963        let root = PathBuf::from("/project");
33964        let path = PathBuf::from("/project/a/b/c.py");
33965        assert!(!preview_relative_path(&root, &path).contains('\\'));
33966    }
33967
33968    // ── wildcard_match ────────────────────────────────────────────────────────
33969
33970    #[test]
33971    fn wildcard_match_exact_equal() {
33972        assert!(wildcard_match("foo", "foo"));
33973    }
33974
33975    #[test]
33976    fn wildcard_match_exact_mismatch() {
33977        assert!(!wildcard_match("foo", "bar"));
33978    }
33979
33980    #[test]
33981    fn wildcard_match_star_suffix() {
33982        assert!(wildcard_match("*.rs", "main.rs"));
33983    }
33984
33985    #[test]
33986    fn wildcard_match_star_middle_requires_suffix() {
33987        assert!(!wildcard_match("a*b", "ac"));
33988    }
33989
33990    #[test]
33991    fn wildcard_match_question_mark_single_char() {
33992        assert!(wildcard_match("f?o", "foo"));
33993    }
33994
33995    #[test]
33996    fn wildcard_match_double_star_nested() {
33997        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
33998    }
33999
34000    #[test]
34001    fn wildcard_match_star_directory_entry() {
34002        assert!(wildcard_match("vendor/*", "vendor/crate"));
34003    }
34004
34005    #[test]
34006    fn wildcard_match_no_cross_prefix() {
34007        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34008    }
34009
34010    // ── should_skip_preview_directory ────────────────────────────────────────
34011
34012    #[test]
34013    fn should_skip_empty_relative_is_false() {
34014        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34015    }
34016
34017    #[test]
34018    fn should_skip_matching_pattern() {
34019        assert!(should_skip_preview_directory(
34020            "vendor",
34021            &["vendor".to_string()]
34022        ));
34023    }
34024
34025    #[test]
34026    fn should_skip_non_matching() {
34027        assert!(!should_skip_preview_directory(
34028            "src",
34029            &["vendor".to_string()]
34030        ));
34031    }
34032
34033    #[test]
34034    fn should_skip_wildcard_prefix() {
34035        assert!(should_skip_preview_directory(
34036            "target/debug",
34037            &["target*".to_string()]
34038        ));
34039    }
34040
34041    // ── should_include_preview_file ───────────────────────────────────────────
34042
34043    #[test]
34044    fn should_include_empty_relative_always_true() {
34045        assert!(should_include_preview_file("", &[], &[]));
34046    }
34047
34048    #[test]
34049    fn should_include_no_patterns_includes_all() {
34050        assert!(should_include_preview_file("src/main.c", &[], &[]));
34051    }
34052
34053    #[test]
34054    fn should_include_excluded_by_pattern() {
34055        assert!(!should_include_preview_file(
34056            "vendor/lib.c",
34057            &[],
34058            &["vendor/*".to_string()]
34059        ));
34060    }
34061
34062    #[test]
34063    fn should_include_include_pattern_filters() {
34064        assert!(!should_include_preview_file(
34065            "tests/test_foo.c",
34066            &["src/*".to_string()],
34067            &[]
34068        ));
34069    }
34070
34071    // ── escape_html ───────────────────────────────────────────────────────────
34072
34073    #[test]
34074    fn escape_html_ampersand() {
34075        assert_eq!(escape_html("a&b"), "a&amp;b");
34076    }
34077
34078    #[test]
34079    fn escape_html_angle_brackets() {
34080        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
34081    }
34082
34083    #[test]
34084    fn escape_html_double_quote() {
34085        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
34086    }
34087
34088    #[test]
34089    fn escape_html_single_quote() {
34090        assert_eq!(escape_html("it's"), "it&#39;s");
34091    }
34092
34093    #[test]
34094    fn escape_html_plain_text_unchanged() {
34095        assert_eq!(escape_html("hello world"), "hello world");
34096    }
34097
34098    // ── sum_added / removed / unmodified code lines ───────────────────────────
34099
34100    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34101        sloc_core::ScanComparison {
34102            summary: sloc_core::SummaryDelta {
34103                baseline_run_id: "base".to_string(),
34104                current_run_id: "curr".to_string(),
34105                baseline_timestamp: chrono::Utc::now(),
34106                current_timestamp: chrono::Utc::now(),
34107                baseline_files: 4,
34108                current_files: 4,
34109                files_analyzed_delta: 0,
34110                baseline_code: 330,
34111                current_code: 400,
34112                code_lines_delta: 70,
34113                baseline_comments: 0,
34114                current_comments: 0,
34115                comment_lines_delta: 0,
34116                blank_lines_delta: 0,
34117                total_lines_delta: 70,
34118                coverage_lines_hit_delta: None,
34119                coverage_line_pct_delta: None,
34120                baseline_coverage_line_pct: None,
34121                current_coverage_line_pct: None,
34122            },
34123            file_deltas: vec![
34124                sloc_core::FileDelta {
34125                    relative_path: "added.rs".to_string(),
34126                    language: Some("Rust".to_string()),
34127                    status: FileChangeStatus::Added,
34128                    baseline_code: 0,
34129                    current_code: 100,
34130                    code_delta: 100,
34131                    baseline_comment: 0,
34132                    current_comment: 0,
34133                    comment_delta: 0,
34134                    baseline_blank: 0,
34135                    current_blank: 0,
34136                    blank_delta: 0,
34137                    total_delta: 100,
34138                },
34139                sloc_core::FileDelta {
34140                    relative_path: "removed.rs".to_string(),
34141                    language: Some("Rust".to_string()),
34142                    status: FileChangeStatus::Removed,
34143                    baseline_code: 50,
34144                    current_code: 0,
34145                    code_delta: -50,
34146                    baseline_comment: 0,
34147                    current_comment: 0,
34148                    comment_delta: 0,
34149                    baseline_blank: 0,
34150                    current_blank: 0,
34151                    blank_delta: 0,
34152                    total_delta: -50,
34153                },
34154                sloc_core::FileDelta {
34155                    relative_path: "modified.rs".to_string(),
34156                    language: Some("Rust".to_string()),
34157                    status: FileChangeStatus::Modified,
34158                    baseline_code: 80,
34159                    current_code: 100,
34160                    code_delta: 20,
34161                    baseline_comment: 0,
34162                    current_comment: 0,
34163                    comment_delta: 0,
34164                    baseline_blank: 0,
34165                    current_blank: 0,
34166                    blank_delta: 0,
34167                    total_delta: 20,
34168                },
34169                sloc_core::FileDelta {
34170                    relative_path: "unchanged.rs".to_string(),
34171                    language: Some("Rust".to_string()),
34172                    status: FileChangeStatus::Unchanged,
34173                    baseline_code: 200,
34174                    current_code: 200,
34175                    code_delta: 0,
34176                    baseline_comment: 0,
34177                    current_comment: 0,
34178                    comment_delta: 0,
34179                    baseline_blank: 0,
34180                    current_blank: 0,
34181                    blank_delta: 0,
34182                    total_delta: 0,
34183                },
34184            ],
34185            files_added: 1,
34186            files_removed: 1,
34187            files_modified: 1,
34188            files_unchanged: 1,
34189        }
34190    }
34191
34192    #[test]
34193    fn sum_added_counts_added_and_positive_modified() {
34194        let cmp = make_mixed_scan_comparison();
34195        assert_eq!(sum_added_code_lines(&cmp), 120);
34196    }
34197
34198    #[test]
34199    fn sum_removed_counts_removed_baseline() {
34200        let cmp = make_mixed_scan_comparison();
34201        assert_eq!(sum_removed_code_lines(&cmp), 50);
34202    }
34203
34204    #[test]
34205    fn sum_unmodified_counts_unchanged_files() {
34206        let cmp = make_mixed_scan_comparison();
34207        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34208    }
34209
34210    // ── detect_coverage_tool ──────────────────────────────────────────────────
34211
34212    #[test]
34213    fn detect_coverage_tool_rust_project() {
34214        let dir = tempfile::tempdir().unwrap();
34215        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34216        let (tool, cmd) = detect_coverage_tool(dir.path());
34217        assert_eq!(tool, Some("cargo-llvm-cov"));
34218        assert!(cmd.is_some());
34219    }
34220
34221    #[test]
34222    fn detect_coverage_tool_java_gradle() {
34223        let dir = tempfile::tempdir().unwrap();
34224        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34225        let (tool, _) = detect_coverage_tool(dir.path());
34226        assert_eq!(tool, Some("jacoco"));
34227    }
34228
34229    #[test]
34230    fn detect_coverage_tool_python_pyproject() {
34231        let dir = tempfile::tempdir().unwrap();
34232        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34233        let (tool, _) = detect_coverage_tool(dir.path());
34234        assert_eq!(tool, Some("pytest-cov"));
34235    }
34236
34237    #[test]
34238    fn detect_coverage_tool_unknown_project() {
34239        let dir = tempfile::tempdir().unwrap();
34240        let (tool, cmd) = detect_coverage_tool(dir.path());
34241        assert!(tool.is_none() && cmd.is_none());
34242    }
34243
34244    // ── sanitize_path_str / display_path ─────────────────────────────────────
34245
34246    #[test]
34247    fn sanitize_path_str_unc_drive_stripped() {
34248        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34249    }
34250
34251    #[test]
34252    fn sanitize_path_str_unc_network_stripped() {
34253        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34254    }
34255
34256    #[test]
34257    fn sanitize_path_str_plain_path_unchanged() {
34258        assert_eq!(
34259            sanitize_path_str("/home/user/project"),
34260            "/home/user/project"
34261        );
34262    }
34263
34264    #[test]
34265    fn display_path_plain_linux_unchanged() {
34266        assert_eq!(
34267            display_path(Path::new("/home/user/project")),
34268            "/home/user/project"
34269        );
34270    }
34271
34272    #[test]
34273    fn display_path_unc_drive_stripped() {
34274        let result = display_path(Path::new(r"\\?\C:\Users\user"));
34275        assert_eq!(result, r"C:\Users\user");
34276    }
34277
34278    #[test]
34279    fn display_path_unc_network_stripped() {
34280        let result = display_path(Path::new(r"\\?\UNC\server\share"));
34281        assert_eq!(result, r"\\server\share");
34282    }
34283}
34284
34285#[cfg(test)]
34286mod coverage_boost_unit_tests {
34287    use super::*;
34288    use std::path::{Path, PathBuf};
34289
34290    // Both scenarios live in one test (sequential, under a Tokio runtime) because
34291    // load_runtime_security_config spawns a pruning task and mutates process-global
34292    // env vars — parallel sub-tests would race on both.
34293    #[tokio::test]
34294    async fn runtime_security_config_scenarios() {
34295        std::env::remove_var("SLOC_API_KEYS");
34296        std::env::remove_var("SLOC_API_KEY");
34297        std::env::remove_var("SLOC_TLS_CERT");
34298        std::env::remove_var("SLOC_TLS_KEY");
34299        std::env::remove_var("SLOC_TRUST_PROXY");
34300        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34301        let cfg = load_runtime_security_config(false);
34302        assert!(cfg.api_keys.is_empty());
34303        assert!(!cfg.tls_enabled);
34304        assert!(!cfg.trust_proxy);
34305
34306        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
34307        std::env::set_var("SLOC_TRUST_PROXY", "1");
34308        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
34309        std::env::set_var("SLOC_RATE_LIMIT", "250");
34310        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
34311        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
34312        let cfg = load_runtime_security_config(true);
34313        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34314        assert!(cfg.trust_proxy);
34315        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34316        std::env::remove_var("SLOC_API_KEYS");
34317        std::env::remove_var("SLOC_TRUST_PROXY");
34318        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34319        std::env::remove_var("SLOC_RATE_LIMIT");
34320        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
34321        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
34322    }
34323
34324    #[test]
34325    fn cors_layer_builds_both_modes() {
34326        let _ = build_cors_layer(true);
34327        let _ = build_cors_layer(false);
34328    }
34329
34330    #[test]
34331    fn primary_lan_ip_callable() {
34332        // May be Some or None depending on the host; both are valid.
34333        let _ = primary_lan_ip();
34334    }
34335
34336    #[test]
34337    fn safe_redirect_allows_relative_rejects_absolute() {
34338        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34339        assert_eq!(safe_redirect("https://evil.example/x"), "/");
34340        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34341        assert_eq!(default_redirect(), "/view-reports");
34342    }
34343
34344    #[test]
34345    fn tarball_size_caps_env_override() {
34346        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
34347        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
34348        let (c, d) = parse_tarball_size_caps();
34349        assert_eq!(c, 1024 * 1024);
34350        assert_eq!(d, 2 * 1024 * 1024);
34351        std::env::remove_var("SLOC_MAX_TARBALL_MB");
34352        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
34353        let (c2, _) = parse_tarball_size_caps();
34354        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34355    }
34356
34357    #[test]
34358    fn upload_path_helpers() {
34359        let base = upload_base_dir();
34360        let staged = upload_staging_path("abc123");
34361        assert!(staged.starts_with(&base));
34362        assert!(
34363            is_upload_tmp_path(&staged),
34364            "staging path is an upload tmp path"
34365        );
34366        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34367    }
34368
34369    #[test]
34370    fn git_clones_dir_env_override() {
34371        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34372        let def = resolve_git_clones_dir(Path::new("/out"));
34373        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34374        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34375        assert_eq!(
34376            resolve_git_clones_dir(Path::new("/out")),
34377            PathBuf::from("/custom/clones")
34378        );
34379        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34380    }
34381
34382    #[test]
34383    fn html_report_file_detection() {
34384        let dir = std::env::temp_dir().join("sloc_html_detect");
34385        let _ = std::fs::create_dir_all(&dir);
34386        let good = dir.join("report_x.html");
34387        std::fs::write(&good, "<html></html>").unwrap();
34388        let bad = dir.join("notes.txt");
34389        std::fs::write(&bad, "x").unwrap();
34390        assert!(is_html_report_file(&good));
34391        assert!(!is_html_report_file(&bad));
34392        assert!(find_html_report_in_dir(&dir).is_some());
34393        let _ = std::fs::remove_dir_all(&dir);
34394    }
34395
34396    #[test]
34397    fn multi_delta_class_and_format() {
34398        assert_eq!(multi_delta_class(5), "pos");
34399        assert_eq!(multi_delta_class(-5), "neg");
34400        assert_eq!(multi_delta_class(0), "zero");
34401        assert_eq!(multi_fmt_delta(3), "+3");
34402        assert_eq!(multi_fmt_delta(-3), "-3");
34403        assert_eq!(multi_fmt_delta(0), "0");
34404    }
34405
34406    #[test]
34407    fn git_clone_dest_sanitizes() {
34408        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34409        assert!(dest.starts_with("/clones"));
34410        let name = dest.file_name().unwrap().to_str().unwrap();
34411        assert!(name
34412            .chars()
34413            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34414    }
34415}
34416
34417#[cfg(test)]
34418mod tests_private {
34419    use super::*;
34420    use std::io::Read;
34421
34422    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
34423
34424    #[test]
34425    fn local_mode_never_refuses_start() {
34426        // Desktop / local mode is open by design regardless of key presence.
34427        assert!(!refuse_unauthenticated_server(false, false));
34428        assert!(!refuse_unauthenticated_server(false, true));
34429    }
34430
34431    #[test]
34432    fn server_mode_with_key_is_allowed() {
34433        assert!(!refuse_unauthenticated_server(true, true));
34434    }
34435
34436    // Env-mutating assertions live in one test so they run sequentially: the
34437    // process-global env var would otherwise race across parallel test threads.
34438    #[test]
34439    fn server_mode_auth_gate_respects_optin() {
34440        std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34441        assert!(
34442            refuse_unauthenticated_server(true, false),
34443            "server mode + no key must fail closed by default"
34444        );
34445        std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1");
34446        assert!(
34447            !refuse_unauthenticated_server(true, false),
34448            "explicit opt-in must allow the unauthenticated server"
34449        );
34450        std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34451    }
34452
34453    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
34454
34455    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
34456    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
34457    /// genuinely malicious archive, which is where the zip-slip guard must hold.
34458    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34459        let mut h = [0u8; 512];
34460        let nb = name.as_bytes();
34461        h[..nb.len()].copy_from_slice(nb);
34462        h[100..108].copy_from_slice(b"0000644\0");
34463        h[108..116].copy_from_slice(b"0000000\0");
34464        h[116..124].copy_from_slice(b"0000000\0");
34465        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34466        h[136..148].copy_from_slice(b"00000000000\0");
34467        h[156] = b'0'; // typeflag: regular file
34468        h[257..263].copy_from_slice(b"ustar\0");
34469        h[263..265].copy_from_slice(b"00");
34470        for b in &mut h[148..156] {
34471            *b = b' ';
34472        }
34473        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34474        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34475
34476        let mut out = h.to_vec();
34477        out.extend_from_slice(data);
34478        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
34479        out.resize(out.len() + 1024, 0); // two trailing zero blocks
34480        out
34481    }
34482
34483    /// A malicious tar whose entry path escapes the destination via `..` must not
34484    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
34485    /// zip-slip guard as a regression test.
34486    #[tokio::test]
34487    async fn tarball_extraction_blocks_zip_slip() {
34488        use std::io::Write as _;
34489
34490        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34491        let staging = base.join("staging");
34492        let tar_gz = base.join("evil.tar.gz");
34493        std::fs::create_dir_all(&base).unwrap();
34494
34495        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
34496        {
34497            let f = std::fs::File::create(&tar_gz).unwrap();
34498            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34499            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34500                .unwrap();
34501            enc.finish().unwrap().flush().unwrap();
34502        }
34503
34504        // Extraction must not write the escaped file beside the staging directory.
34505        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34506
34507        let escaped = base.join("escaped.txt");
34508        assert!(
34509            !escaped.exists(),
34510            "zip-slip entry escaped staging to {}",
34511            escaped.display()
34512        );
34513
34514        let _ = std::fs::remove_dir_all(&base);
34515    }
34516
34517    #[test]
34518    fn size_limit_reader_zero_remaining_returns_error() {
34519        let data = b"hello world";
34520        let mut reader = SizeLimitReader {
34521            inner: &data[..],
34522            remaining: 0,
34523        };
34524        let mut buf = [0u8; 4];
34525        assert!(reader.read(&mut buf).is_err());
34526    }
34527
34528    #[test]
34529    fn size_limit_reader_counts_bytes() {
34530        let data = b"hello world";
34531        let mut reader = SizeLimitReader {
34532            inner: &data[..],
34533            remaining: 5,
34534        };
34535        let mut buf = [0u8; 4];
34536        let n = reader.read(&mut buf).unwrap();
34537        assert_eq!(n, 4);
34538        assert_eq!(reader.remaining, 1);
34539    }
34540
34541    #[test]
34542    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34543        let uuid = "12345678-1234-1234-1234-123456789012";
34544        let (id, path) = resolve_or_create_staging(Some(uuid));
34545        assert_eq!(id, uuid);
34546        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34547    }
34548
34549    #[test]
34550    fn resolve_or_create_staging_with_none_creates_new() {
34551        let (id1, _) = resolve_or_create_staging(None);
34552        let (id2, _) = resolve_or_create_staging(None);
34553        assert_ne!(id1, id2);
34554    }
34555
34556    #[test]
34557    fn resolve_or_create_staging_with_path_separator_creates_new() {
34558        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
34559        let (id, _) = resolve_or_create_staging(Some("has/slash"));
34560        assert_ne!(id, "has/slash");
34561    }
34562
34563    #[test]
34564    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34565        use std::net::IpAddr;
34566        use std::str::FromStr;
34567        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34568        let ip = IpAddr::from_str("192.168.1.1").unwrap();
34569        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34570    }
34571
34572    #[test]
34573    fn is_auth_locked_out_expired_entry_removed() {
34574        use std::net::IpAddr;
34575        use std::str::FromStr;
34576        let limiter = IpRateLimiter::new(
34577            Duration::from_mins(1),
34578            100,
34579            1, // 1 failure triggers lockout
34580            Duration::from_millis(1),
34581        );
34582        let ip = IpAddr::from_str("192.168.1.2").unwrap();
34583        limiter.record_auth_failure(ip);
34584        // Wait for the 1ms window to expire
34585        std::thread::sleep(Duration::from_millis(10));
34586        // Expired entry should be removed, returning false
34587        assert!(!limiter.is_auth_locked_out(ip));
34588    }
34589
34590    #[test]
34591    fn is_auth_locked_out_within_window_returns_true() {
34592        use std::net::IpAddr;
34593        use std::str::FromStr;
34594        let limiter = IpRateLimiter::new(
34595            Duration::from_mins(1),
34596            100,
34597            2, // 2 failures triggers lockout
34598            Duration::from_hours(1),
34599        );
34600        let ip = IpAddr::from_str("192.168.1.3").unwrap();
34601        limiter.record_auth_failure(ip);
34602        limiter.record_auth_failure(ip);
34603        assert!(limiter.is_auth_locked_out(ip));
34604    }
34605
34606    // ── output_folder_hint ───────────────────────────────────────────────────────
34607
34608    #[test]
34609    fn output_folder_hint_strips_json_subdir() {
34610        use std::path::Path;
34611        let path = Path::new("/output/scan1/json/result.json");
34612        let hint = output_folder_hint(path);
34613        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34614    }
34615
34616    #[test]
34617    fn output_folder_hint_strips_html_subdir() {
34618        use std::path::Path;
34619        let path = Path::new("/output/scan1/html/report.html");
34620        let hint = output_folder_hint(path);
34621        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34622    }
34623
34624    #[test]
34625    fn output_folder_hint_strips_pdf_subdir() {
34626        use std::path::Path;
34627        let path = Path::new("/output/scan1/pdf/report.pdf");
34628        let hint = output_folder_hint(path);
34629        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34630    }
34631
34632    #[test]
34633    fn output_folder_hint_strips_excel_subdir() {
34634        use std::path::Path;
34635        let path = Path::new("/output/scan1/excel/report.xlsx");
34636        let hint = output_folder_hint(path);
34637        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34638    }
34639
34640    #[test]
34641    fn output_folder_hint_flat_layout_returns_direct_parent() {
34642        use std::path::Path;
34643        let path = Path::new("/output/scan1/result.json");
34644        let hint = output_folder_hint(path);
34645        assert!(
34646            hint.ends_with("scan1"),
34647            "expected direct parent, got: {hint}"
34648        );
34649    }
34650
34651    #[test]
34652    fn output_folder_hint_other_subdir_name_not_stripped() {
34653        use std::path::Path;
34654        // "data" is not one of the named artifact subdirs — parent is kept as-is
34655        let path = Path::new("/output/scan1/data/result.json");
34656        let hint = output_folder_hint(path);
34657        assert!(
34658            hint.ends_with("data"),
34659            "non-artifact subdir must not be stripped, got: {hint}"
34660        );
34661    }
34662
34663    // ── find_file_by_ext ─────────────────────────────────────────────────────────
34664
34665    #[test]
34666    fn find_file_by_ext_finds_matching_file() {
34667        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
34668        let _ = fs::create_dir_all(&dir);
34669        let f = dir.join("report.pdf");
34670        let _ = fs::write(&f, b"dummy");
34671        let result = find_file_by_ext(&dir, "pdf");
34672        assert!(result.is_some(), "expected to find report.pdf");
34673        let _ = fs::remove_dir_all(&dir);
34674    }
34675
34676    #[test]
34677    fn find_file_by_ext_returns_none_for_missing_ext() {
34678        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
34679        let _ = fs::create_dir_all(&dir);
34680        let f = dir.join("report.json");
34681        let _ = fs::write(&f, b"{}");
34682        let result = find_file_by_ext(&dir, "pdf");
34683        assert!(result.is_none());
34684        let _ = fs::remove_dir_all(&dir);
34685    }
34686
34687    #[test]
34688    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
34689        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
34690        assert!(find_file_by_ext(dir, "json").is_none());
34691    }
34692
34693    // ── collect_result_json_candidates ───────────────────────────────────────────
34694
34695    #[test]
34696    fn collect_result_json_candidates_flat_root() {
34697        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
34698        let _ = fs::create_dir_all(&root);
34699        let _ = fs::write(root.join("result.json"), b"{}");
34700        let candidates = collect_result_json_candidates(&root);
34701        assert!(!candidates.is_empty(), "should find result.json at root");
34702        let _ = fs::remove_dir_all(&root);
34703    }
34704
34705    #[test]
34706    fn collect_result_json_candidates_legacy_subdir() {
34707        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
34708        let sub = root.join("scanA");
34709        let _ = fs::create_dir_all(&sub);
34710        let _ = fs::write(sub.join("result.json"), b"{}");
34711        let candidates = collect_result_json_candidates(&root);
34712        assert!(
34713            !candidates.is_empty(),
34714            "should find result.json in legacy subdir"
34715        );
34716        let _ = fs::remove_dir_all(&root);
34717    }
34718
34719    #[test]
34720    fn collect_result_json_candidates_structured_json_subdir() {
34721        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
34722        let json_sub = root.join("scanB").join("json");
34723        let _ = fs::create_dir_all(&json_sub);
34724        let _ = fs::write(json_sub.join("result.json"), b"{}");
34725        let candidates = collect_result_json_candidates(&root);
34726        assert!(
34727            !candidates.is_empty(),
34728            "should find result.json inside <subdir>/json/"
34729        );
34730        let _ = fs::remove_dir_all(&root);
34731    }
34732
34733    #[test]
34734    fn collect_result_json_candidates_empty_dir() {
34735        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
34736        let _ = fs::create_dir_all(&root);
34737        let candidates = collect_result_json_candidates(&root);
34738        assert!(candidates.is_empty());
34739        let _ = fs::remove_dir_all(&root);
34740    }
34741}