Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{verify_audit_file, AuditVerifyReport};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35    collections::{HashMap, VecDeque},
36    fmt::Write,
37    fs,
38    net::{IpAddr, SocketAddr},
39    path::{Path, PathBuf},
40    process::Stdio,
41    sync::{Arc, OnceLock},
42    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48    body::Body,
49    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
50    http::{header, HeaderValue, Request, StatusCode},
51    middleware::{self, Next},
52    response::{Html, IntoResponse, Response},
53    routing::{get, post},
54    Json, Router,
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62    MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73    analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
74    CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
75    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
76};
77use sloc_report::{
78    render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
79    write_pdf_from_run, ReportDeltaContext,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83/// Windows-only helpers that force the native file-picker dialog into the
84/// foreground instead of appearing minimised behind other windows.
85///
86/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
87/// foreground thread so that windows created on our thread inherit focus; and
88/// (b) spin a polling watcher that finds the dialog by title and calls
89/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
90#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94    #[cfg(feature = "native-dialog")]
95    use std::mem::size_of;
96
97    type HWND = *mut core::ffi::c_void;
98    type DWORD = u32;
99    type UINT = u32;
100    type BOOL = i32;
101
102    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
103    #[cfg(feature = "native-dialog")]
104    #[repr(C)]
105    #[allow(non_snake_case)]
106    struct FLASHWINFO {
107        cbSize: UINT,
108        hwnd: HWND,
109        dwFlags: DWORD,
110        uCount: UINT,
111        dwTimeout: DWORD,
112    }
113
114    #[cfg(feature = "native-dialog")]
115    const FLASHW_ALL: DWORD = 0x3;
116    #[cfg(feature = "native-dialog")]
117    const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119    #[link(name = "user32")]
120    extern "system" {
121        fn GetForegroundWindow() -> HWND;
122        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124        fn BringWindowToTop(hWnd: HWND) -> BOOL;
125        fn SetWindowPos(
126            hWnd: HWND,
127            hWndAfter: HWND,
128            x: i32,
129            y: i32,
130            cx: i32,
131            cy: i32,
132            flags: UINT,
133        ) -> BOOL;
134        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136        #[cfg(feature = "native-dialog")]
137        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139        fn FindWindowExW(
140            hWndParent: HWND,
141            hWndChildAfter: HWND,
142            lpszClass: *const u16,
143            lpszWindow: *const u16,
144        ) -> HWND;
145        // Undocumented but present on all Windows versions since XP; bypasses
146        // the foreground-lock that blocks SetForegroundWindow from non-foreground
147        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
148        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149    }
150
151    #[link(name = "kernel32")]
152    extern "system" {
153        fn GetCurrentThreadId() -> DWORD;
154    }
155
156    #[link(name = "shell32")]
157    extern "system" {
158        // Opens a folder (or file) via the Windows shell.  Passing the current
159        // foreground window as `hwnd` gives the new window proper activation
160        // context so it surfaces in the foreground without needing
161        // AttachThreadInput or SetForegroundWindow hacks.
162        fn ShellExecuteW(
163            hwnd: HWND,
164            lpOperation: *const u16,
165            lpFile: *const u16,
166            lpParameters: *const u16,
167            lpDirectory: *const u16,
168            nShowCmd: i32,
169        ) -> isize; // HINSTANCE (>32 = success)
170    }
171
172    /// Attaches our thread's input to the foreground window's thread so that
173    /// windows created on our thread inherit foreground focus.  Returns the
174    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
175    /// the thread was already the foreground thread.
176    #[cfg(feature = "native-dialog")]
177    pub fn attach_to_foreground() -> DWORD {
178        unsafe {
179            let fg_hwnd = GetForegroundWindow();
180            if fg_hwnd.is_null() {
181                return 0;
182            }
183            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184            let my_tid = GetCurrentThreadId();
185            if fg_tid == my_tid {
186                return 0;
187            }
188            AttachThreadInput(my_tid, fg_tid, 1);
189            fg_tid
190        }
191    }
192
193    /// Undoes `attach_to_foreground`.
194    #[cfg(feature = "native-dialog")]
195    pub fn detach_from_foreground(fg_tid: DWORD) {
196        if fg_tid == 0 {
197            return;
198        }
199        unsafe {
200            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201        }
202    }
203
204    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205        let mut existing = std::collections::HashSet::new();
206        let mut prev: HWND = core::ptr::null_mut();
207        loop {
208            let w = FindWindowExW(
209                core::ptr::null_mut(),
210                prev,
211                class_w.as_ptr(),
212                core::ptr::null(),
213            );
214            if w.is_null() {
215                break;
216            }
217            existing.insert(w as usize);
218            prev = w;
219        }
220        existing
221    }
222
223    unsafe fn find_new_explorer_hwnd(
224        class_w: &[u16],
225        existing: &std::collections::HashSet<usize>,
226    ) -> Option<HWND> {
227        let mut prev: HWND = core::ptr::null_mut();
228        loop {
229            let w = FindWindowExW(
230                core::ptr::null_mut(),
231                prev,
232                class_w.as_ptr(),
233                core::ptr::null(),
234            );
235            if w.is_null() {
236                return None;
237            }
238            if !existing.contains(&(w as usize)) {
239                return Some(w);
240            }
241            prev = w;
242        }
243    }
244
245    unsafe fn bring_to_front(hwnd: HWND) {
246        // Surfacing a window owned by another process (Explorer) from a
247        // background thread is blocked by Windows' foreground lock:
248        // SetForegroundWindow silently fails and only the taskbar button
249        // flashes.  The reliable workaround is to temporarily attach our input
250        // queue to the thread that currently owns the foreground window — while
251        // attached, SetForegroundWindow/BringWindowToTop actually activate the
252        // window instead of merely flashing it.
253        let my_tid = GetCurrentThreadId();
254        let fg_hwnd = GetForegroundWindow();
255        let fg_tid = if fg_hwnd.is_null() {
256            0
257        } else {
258            GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
259        };
260        let attached = fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
261
262        // SW_RESTORE = 9 — un-minimise the Explorer window (it may have opened
263        // as a taskbar button) without forcing a full-screen maximise.
264        ShowWindow(hwnd, 9);
265        BringWindowToTop(hwnd);
266        SetForegroundWindow(hwnd);
267        // Extra belt-and-braces activation that also bypasses the foreground
268        // lock on older Windows builds.
269        SwitchToThisWindow(hwnd, 1);
270
271        // Force the Z-order to the very top regardless of the foreground-lock
272        // outcome by flipping TOPMOST on then off, so the window jumps above all
273        // others without staying pinned. HWND_TOPMOST = -1, HWND_NOTOPMOST = -2;
274        // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE = 0x0013.
275        SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
276        SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
277
278        if attached {
279            AttachThreadInput(my_tid, fg_tid, 0);
280        }
281    }
282
283    /// Opens `path` in Windows Explorer and forces it to the foreground.
284    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
285    /// caller is not the foreground process (the browser is).  After launching,
286    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
287    /// an undocumented API that bypasses Windows' foreground-lock restriction
288    /// so the window surfaces regardless of which process currently has focus.
289    pub fn open_folder_foreground(path: std::path::PathBuf) {
290        std::thread::spawn(move || {
291            use std::os::windows::ffi::OsStrExt;
292
293            let op: Vec<u16> = "explore\0".encode_utf16().collect();
294            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
295            path_w.push(0);
296            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
297
298            unsafe {
299                // Snapshot every existing Explorer window before we launch so
300                // we can identify the newly created one.
301                let existing = snapshot_explorer_hwnds(&class_w);
302                let fg_hwnd = GetForegroundWindow();
303                // SW_SHOWNORMAL = 1
304                ShellExecuteW(
305                    fg_hwnd,
306                    op.as_ptr(),
307                    path_w.as_ptr(),
308                    core::ptr::null(),
309                    core::ptr::null(),
310                    1,
311                );
312
313                // Poll up to ~3 s for a new CabinetWClass window to appear,
314                // then use SwitchToThisWindow (bypasses foreground-lock) to
315                // bring it in front of the browser and everything else.
316                for _ in 0..40 {
317                    std::thread::sleep(std::time::Duration::from_millis(75));
318                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
319                        bring_to_front(w);
320                        return;
321                    }
322                }
323
324                // Fallback: Explorer reused an existing window — bring whichever
325                // CabinetWClass window is first in Z-order to the front.
326                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
327                if !w.is_null() {
328                    bring_to_front(w);
329                }
330            }
331        });
332    }
333
334    /// Spawns a short-lived watcher thread that polls for a dialog window
335    /// matching `title` and, once found, forces it to the foreground and
336    /// flashes its taskbar button until the user interacts with it.
337    #[cfg(feature = "native-dialog")]
338    pub fn flash_dialog_when_ready(title: String) {
339        std::thread::spawn(move || {
340            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
341            for _ in 0..40 {
342                std::thread::sleep(std::time::Duration::from_millis(80));
343                unsafe {
344                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
345                    if !hwnd.is_null() {
346                        SetForegroundWindow(hwnd);
347                        BringWindowToTop(hwnd);
348                        #[allow(non_snake_case)]
349                        FlashWindowEx(&FLASHWINFO {
350                            // size_of returns usize; Win32 struct field is u32 (UINT).
351                            // struct size fits trivially within u32.
352                            #[allow(clippy::cast_possible_truncation)]
353                            cbSize: size_of::<FLASHWINFO>() as UINT,
354                            hwnd,
355                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
356                            uCount: 3,
357                            dwTimeout: 0,
358                        });
359                        break;
360                    }
361                }
362            }
363        });
364    }
365}
366
367/// Sliding-window rate limiter keyed by client IP.
368/// Uses only std primitives — no external crate required.
369pub(crate) struct IpRateLimiter {
370    window: Duration,
371    max_requests: usize,
372    pub(crate) auth_lockout_threshold: u32,
373    auth_lockout_window: Duration,
374    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
375    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
376}
377
378impl IpRateLimiter {
379    pub(crate) fn new(
380        window: Duration,
381        max_requests: usize,
382        auth_lockout_threshold: u32,
383        auth_lockout_window: Duration,
384    ) -> Self {
385        Self {
386            window,
387            max_requests,
388            auth_lockout_threshold,
389            auth_lockout_window,
390            state: std::sync::Mutex::new(HashMap::new()),
391            auth_failures: std::sync::Mutex::new(HashMap::new()),
392        }
393    }
394
395    // The MutexGuard `state` must live as long as `bucket` borrows from it,
396    // so it cannot be dropped any earlier than the end of the inner block.
397    #[allow(clippy::significant_drop_tightening)]
398    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
399        let now = Instant::now();
400        let cutoff = now.checked_sub(self.window).unwrap_or(now);
401        let mut state = self
402            .state
403            .lock()
404            .unwrap_or_else(std::sync::PoisonError::into_inner);
405        if state.len() > 10_000 {
406            state.retain(|_, bucket| {
407                while bucket.front().is_some_and(|t| *t <= cutoff) {
408                    bucket.pop_front();
409                }
410                !bucket.is_empty()
411            });
412        }
413        let bucket = state.entry(ip).or_default();
414        while bucket.front().is_some_and(|t| *t <= cutoff) {
415            bucket.pop_front();
416        }
417        if bucket.len() >= self.max_requests {
418            false
419        } else {
420            bucket.push_back(now);
421            true
422        }
423    }
424
425    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
426        let now = Instant::now();
427        let mut map = self
428            .auth_failures
429            .lock()
430            .unwrap_or_else(std::sync::PoisonError::into_inner);
431        map.entry(ip)
432            .and_modify(|e| {
433                e.0 += 1;
434                e.1 = now;
435            })
436            .or_insert_with(|| (1, now));
437    }
438
439    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
440        let mut map = self
441            .auth_failures
442            .lock()
443            .unwrap_or_else(std::sync::PoisonError::into_inner);
444        let expired = map
445            .get(&ip)
446            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
447        if expired {
448            map.remove(&ip);
449            return false;
450        }
451        map.get(&ip)
452            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
453    }
454
455    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
456        let map = self
457            .auth_failures
458            .lock()
459            .unwrap_or_else(std::sync::PoisonError::into_inner);
460        map.get(&ip).map_or(0, |e| {
461            self.auth_lockout_window
462                .checked_sub(e.1.elapsed())
463                .map_or(0, |r| r.as_secs())
464        })
465    }
466
467    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
468        tokio::spawn(async move {
469            let mut interval = tokio::time::interval(Duration::from_mins(1));
470            interval.tick().await; // consume the immediate first tick
471            loop {
472                interval.tick().await;
473                let now = Instant::now();
474                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
475                {
476                    let mut state = limiter
477                        .state
478                        .lock()
479                        .unwrap_or_else(std::sync::PoisonError::into_inner);
480                    state.retain(|_, bucket| {
481                        while bucket.front().is_some_and(|t| *t <= cutoff) {
482                            bucket.pop_front();
483                        }
484                        !bucket.is_empty()
485                    });
486                }
487                {
488                    let mut auth = limiter
489                        .auth_failures
490                        .lock()
491                        .unwrap_or_else(std::sync::PoisonError::into_inner);
492                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
493                }
494            }
495        });
496    }
497}
498
499/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
500/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
501/// files but never triggers a scan.
502fn spawn_upload_staging_cleanup() {
503    tokio::spawn(async move {
504        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
505            .ok()
506            .and_then(|v| v.parse().ok())
507            .unwrap_or(4);
508        let ttl_secs = ttl_hours * 3600;
509        let mut interval = tokio::time::interval(Duration::from_hours(1));
510        interval.tick().await; // consume the immediate first tick
511        loop {
512            interval.tick().await;
513            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
514            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
515                continue;
516            };
517            while let Ok(Some(entry)) = dir.next_entry().await {
518                let path = entry.path();
519                let age_secs = tokio::fs::metadata(&path)
520                    .await
521                    .ok()
522                    .and_then(|m| m.modified().ok())
523                    .and_then(|t| t.elapsed().ok())
524                    .map_or(0, |d| d.as_secs());
525                if age_secs > ttl_secs {
526                    tracing::debug!(
527                        event = "upload_staging_cleanup",
528                        path = %path.display(),
529                        age_secs,
530                        "removing stale upload staging directory"
531                    );
532                    let _ = tokio::fs::remove_dir_all(&path).await;
533                }
534            }
535        }
536    });
537}
538
539/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
540#[derive(Clone, Debug, Default)]
541struct RunResultContext {
542    prev_entry: Option<RegistryEntry>,
543    prev_scan_count: usize,
544    project_path: String,
545    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
546    cocomo_mode: String,
547    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
548    complexity_alert: u32,
549    /// Whether duplicate files should be excluded from displayed SLOC totals.
550    #[allow(dead_code)]
551    exclude_duplicates: bool,
552}
553
554/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
555#[derive(Clone)]
556enum AsyncRunState {
557    Running {
558        started_at: std::time::Instant,
559        cancel_token: Arc<std::sync::atomic::AtomicBool>,
560        phase: Arc<std::sync::Mutex<String>>,
561        files_done: Arc<std::sync::atomic::AtomicUsize>,
562        files_total: Arc<std::sync::atomic::AtomicUsize>,
563    },
564    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
565    Complete {
566        run_id: String,
567    },
568    Failed {
569        message: String,
570    },
571    Cancelled,
572}
573
574/// A saved scan configuration profile — stores the form parameters so users can
575/// re-run a favourite scan with one click.
576#[derive(Debug, Clone, Serialize, Deserialize)]
577struct ScanProfile {
578    id: String,
579    name: String,
580    created_at: String,
581    /// The raw scan-form parameters serialized as JSON.
582    params: serde_json::Value,
583}
584
585#[derive(Debug, Clone, Default, Serialize, Deserialize)]
586struct ScanProfileStore {
587    profiles: Vec<ScanProfile>,
588}
589
590impl ScanProfileStore {
591    fn load(path: &std::path::Path) -> Self {
592        fs::read_to_string(path)
593            .ok()
594            .and_then(|s| serde_json::from_str(&s).ok())
595            .unwrap_or_default()
596    }
597
598    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
599        if let Some(parent) = path.parent() {
600            fs::create_dir_all(parent)?;
601        }
602        let json = serde_json::to_string_pretty(self)?;
603        fs::write(path, json)?;
604        Ok(())
605    }
606}
607
608/// Server-side session record. `absolute_expiry` is the hard 8-hour cap (unchanged);
609/// `last_seen` supports the optional sliding idle timeout (see `session_idle_timeout`).
610#[derive(Clone, Copy)]
611pub(crate) struct SessionState {
612    pub(crate) absolute_expiry: Instant,
613    pub(crate) last_seen: Instant,
614}
615
616#[derive(Clone)]
617pub(crate) struct AppState {
618    pub(crate) base_config: AppConfig,
619    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
620    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
621    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
622    pub(crate) registry_path: PathBuf,
623    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
624    pub(crate) server_mode: bool,
625    /// Operator explicitly accepted running server mode with no API key
626    /// (`SLOC_ALLOW_UNAUTHENTICATED=1`). When false, an unauthenticated server-mode
627    /// request fails closed with 503 instead of being served open.
628    pub(crate) allow_unauthenticated: bool,
629    pub(crate) tls_enabled: bool,
630    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
631    /// Read-only credentials (`SLOC_API_KEYS_READONLY`): authenticate for safe
632    /// (GET/HEAD/OPTIONS) requests but are rejected on state-changing methods.
633    /// Empty by default, so all keys are full-access — the prior behaviour.
634    pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
635    pub(crate) rate_limiter: Arc<IpRateLimiter>,
636    pub(crate) trust_proxy: bool,
637    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
638    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
639    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
640    /// Directory where remote repositories are cloned for git-browser scans.
641    pub(crate) git_clones_dir: PathBuf,
642    /// Persisted list of webhook / poll schedules.
643    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
644    pub(crate) schedules_path: PathBuf,
645    /// Named scan profiles saved by the user via the web UI.
646    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
647    pub(crate) scan_profiles_path: PathBuf,
648    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
649    /// Persisted Confluence integration settings.
650    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
651    pub(crate) confluence_path: PathBuf,
652    /// Directories the user has pinned for auto-scanning of external reports.
653    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
654    pub(crate) watched_dirs_path: PathBuf,
655    /// Persisted auto-cleanup policy (age/count limits + interval).
656    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
657    pub(crate) cleanup_policy_path: PathBuf,
658    /// Handle for the running cleanup background task; replaced on policy change.
659    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
660}
661
662type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
663
664/// Parameters for the fire-and-forget HTML + PDF background task.
665
666#[derive(Clone, Debug)]
667pub(crate) struct RunArtifacts {
668    output_dir: PathBuf,
669    html_path: Option<PathBuf>,
670    pdf_path: Option<PathBuf>,
671    json_path: Option<PathBuf>,
672    csv_path: Option<PathBuf>,
673    xlsx_path: Option<PathBuf>,
674    scan_config_path: Option<PathBuf>,
675    report_title: String,
676    result_context: RunResultContext,
677}
678
679#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
680fn build_router(state: AppState) -> Router {
681    let protected = Router::new()
682        .route("/", get(splash))
683        .route("/scan-setup", get(scan_setup_handler))
684        .route("/scan", get(index))
685        .route("/analyze", post(analyze_handler))
686        .route("/preview", get(preview_handler))
687        .route("/api/suggest-coverage", get(api_suggest_coverage))
688        .route("/pick-directory", get(pick_directory_handler))
689        .route("/open-path", get(open_path_handler))
690        .route("/pick-file", get(pick_file_handler))
691        .route(
692            "/api/upload-directory",
693            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
694        )
695        .route(
696            "/api/upload-file",
697            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
698        )
699        .route(
700            "/api/upload-tarball",
701            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
702            // The handler also enforces this limit during streaming so both layers agree.
703            post(upload_tarball_handler)
704                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
705        )
706        .route("/locate-report", post(locate_report_handler))
707        .route("/locate-reports-dir", post(locate_reports_dir_handler))
708        .route("/relocate-scan", post(relocate_scan_handler))
709        .route("/watched-dirs/add", post(add_watched_dir_handler))
710        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
711        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
712        .route("/view-reports", get(history_handler))
713        .route("/compare-scans", get(compare_select_handler))
714        .route("/compare", get(compare_handler))
715        .route("/multi-compare", get(multi_compare_handler))
716        .route("/images/{folder}/{file}", get(image_handler))
717        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
718        .route("/api/metrics/latest", get(api_metrics_latest_handler))
719        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
720        .route("/api/metrics/history", get(api_metrics_history_handler))
721        .route("/api/metrics/churn", get(api_metrics_churn_handler))
722        .route(
723            "/api/metrics/submodules",
724            get(api_metrics_submodules_handler),
725        )
726        .route("/api/ingest", post(api_ingest_handler))
727        .route("/api/project-history", get(project_history_handler))
728        .route("/trend-reports", get(trend_report_handler))
729        .route("/test-metrics", get(test_metrics_handler))
730        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
731        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
732        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
733        .route("/runs/result/{run_id}", get(async_run_result_handler))
734        .route("/embed/summary", get(embed_handler))
735        // ── Git browser ────────────────────────────────────────────────────────
736        .route("/git-browser", get(git_browser::git_browser_handler))
737        .route("/api/git/refs", get(git_browser::api_list_refs))
738        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
739        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
740        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
741        // The request body is the full rendered HTML report, whose size scales
742        // with file count — large repos (Compare Scans, Files, Trend, Test
743        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
744        .route(
745            "/export/pdf",
746            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
747        )
748        // ── Config export / import ─────────────────────────────────────────────
749        .route("/export-config", get(export_config_handler))
750        .route("/import-config", post(import_config_handler))
751        // ── Scan profiles ──────────────────────────────────────────────────────
752        .route("/api/scan-profiles", get(api_list_scan_profiles))
753        .route("/api/scan-profiles", post(api_save_scan_profile))
754        .route(
755            "/api/scan-profiles/{id}",
756            axum::routing::delete(api_delete_scan_profile),
757        )
758        // ── Integrations (webhooks + Confluence) ──────────────────────────────
759        .route("/integrations", get(integrations::integrations_handler))
760        .route(
761            "/webhook-setup",
762            get(|| async { axum::response::Redirect::permanent("/integrations") }),
763        )
764        .route(
765            "/confluence-setup",
766            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
767        )
768        .route("/api/schedules", get(git_webhook::api_list_schedules))
769        .route("/api/schedules", post(git_webhook::api_create_schedule))
770        .route(
771            "/api/schedules",
772            axum::routing::delete(git_webhook::api_delete_schedule),
773        )
774        .route(
775            "/api/confluence/config",
776            get(confluence::api_get_confluence_config),
777        )
778        .route(
779            "/api/confluence/config",
780            post(confluence::api_save_confluence_config),
781        )
782        .route(
783            "/api/confluence/test",
784            post(confluence::api_test_confluence),
785        )
786        .route(
787            "/api/confluence/post",
788            post(confluence::api_post_to_confluence),
789        )
790        .route(
791            "/api/confluence/wiki-markup",
792            get(confluence::api_wiki_markup),
793        )
794        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
795        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
796        .route(
797            "/api/runs/{run_id}",
798            axum::routing::delete(delete_run_handler),
799        )
800        .route("/api/runs/cleanup", post(cleanup_runs_handler))
801        // ── Auto-cleanup policy ────────────────────────────────────────────────
802        .route(
803            "/api/cleanup-policy",
804            get(api_get_cleanup_policy)
805                .post(api_save_cleanup_policy)
806                .delete(api_delete_cleanup_policy),
807        )
808        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
809        // ── REST API reference page ────────────────────────────────────────────
810        .route("/api-docs", get(api_docs_handler))
811        // ── Prometheus metrics — behind API-key auth ───────────────────────────
812        .route("/metrics", get(metrics_handler))
813        .route_layer(middleware::from_fn_with_state(
814            state.clone(),
815            auth::require_api_key,
816        ));
817
818    protected
819        .route("/healthz", get(healthz))
820        .route("/api/health", get(healthz))
821        .route("/api/version", get(api_version_handler))
822        .route("/api/openapi.yaml", get(openapi_yaml_handler))
823        .route("/llms.txt", get(llms_txt_handler))
824        .route("/llms-full.txt", get(llms_full_txt_handler))
825        .route("/badge/{metric}", get(badge_handler))
826        .route("/static/chart.js", get(chart_js_handler))
827        .route("/static/chart-report.js", get(report_chart_js_handler))
828        .route("/auth/login", get(auth::auth_login_get))
829        .route("/auth/login", post(auth::auth_login_post))
830        .route("/auth/logout", post(auth::auth_logout))
831        // Pre-access consent acknowledgement endpoint (public; exempt from the gate).
832        .route("/auth/consent", get(auth::auth_consent_accept))
833        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
834        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
835        .route(
836            "/webhooks/github",
837            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
838        )
839        .route(
840            "/webhooks/gitlab",
841            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
842        )
843        .route(
844            "/webhooks/bitbucket",
845            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
846        )
847        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
848        .layer(middleware::from_fn(consent_gate))
849        .layer(middleware::from_fn(csrf_protect))
850        .layer(middleware::from_fn_with_state(
851            state.clone(),
852            add_security_headers,
853        ))
854        .layer(build_cors_layer(state.server_mode))
855        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
856        .with_state(state)
857}
858
859/// Bearer token used by `make_test_router_server_mode()` test routers.
860/// Tests that exercise server-mode paths must include this key in their requests.
861pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
862
863/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
864/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
865/// builders below start from this and override only the fields they care about.
866///
867/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
868fn test_app_state(tmp_subdir: &str) -> AppState {
869    std::env::set_var("SLOC_HEADLESS", "1");
870    // Root every router in its OWN temp subdirectory. Multiple routers share a
871    // namespace prefix (e.g. "sloc_test"), so a fixed name would make parallel
872    // tests read/write the same registry.json + artifact tree and race — a
873    // concurrently-mutated shared store is what made multi_compare_* flaky.
874    // A per-call counter (plus PID, to avoid leftover-dir collisions across
875    // runs) guarantees isolation, honouring this fn's "per-test subdir" contract.
876    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
877    let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
878    let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
879    AppState {
880        base_config: AppConfig::default(),
881        artifacts: Arc::new(Mutex::new(HashMap::new())),
882        async_runs: Arc::new(Mutex::new(HashMap::new())),
883        registry: Arc::new(Mutex::new(ScanRegistry::default())),
884        registry_path: tmp.join("registry.json"),
885        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
886        server_mode: false,
887        allow_unauthenticated: false,
888        tls_enabled: false,
889        api_keys: Arc::new(vec![]),
890        readonly_api_keys: Arc::new(vec![]),
891        rate_limiter: Arc::new(IpRateLimiter::new(
892            Duration::from_mins(1),
893            600,
894            10,
895            Duration::from_hours(1),
896        )),
897        trust_proxy: false,
898        trusted_proxy_ips: vec![],
899        git_clones_dir: tmp.join("git-clones"),
900        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
901        schedules_path: tmp.join("schedules.json"),
902        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
903        scan_profiles_path: tmp.join("scan_profiles.json"),
904        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
905        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
906        confluence_path: tmp.join("confluence_config.json"),
907        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
908        watched_dirs_path: tmp.join("watched_dirs.json"),
909        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
910        cleanup_policy_path: tmp.join("cleanup_policy.json"),
911        cleanup_task_handle: Arc::new(Mutex::new(None)),
912    }
913}
914
915/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
916pub fn make_test_router() -> Router {
917    build_router(test_app_state("sloc_test"))
918}
919
920/// Test router with one API key pre-loaded. Used by auth integration tests.
921pub fn make_test_router_with_key(api_key: &str) -> Router {
922    let mut state = test_app_state("sloc_test_key");
923    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
924    build_router(state)
925}
926
927/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
928/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
929/// preview restrictions.
930pub fn make_test_router_server_mode() -> Router {
931    let mut state = test_app_state("sloc_test_server");
932    state.server_mode = true;
933    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
934        TEST_SERVER_MODE_API_KEY.to_owned(),
935    ))]);
936    build_router(state)
937}
938
939/// Server-mode test router with `allowed_scan_roots` configured.
940///
941/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
942/// success, unresolved path, and out-of-root rejection) that the empty-roots
943/// router cannot reach.
944pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
945    let mut state = test_app_state("sloc_test_server_roots");
946    state.server_mode = true;
947    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
948        TEST_SERVER_MODE_API_KEY.to_owned(),
949    ))]);
950    state.base_config.discovery.allowed_scan_roots = roots;
951    build_router(state)
952}
953
954/// Test router where the analysis semaphore is pre-exhausted (0 permits).
955/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
956pub fn make_test_router_exhausted_semaphore() -> Router {
957    let mut state = test_app_state("sloc_test_exhaust");
958    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
959    build_router(state)
960}
961
962/// Test router with a very tight rate limit (3 req/min). The third request from
963/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
964pub fn make_test_router_tight_rate_limit() -> Router {
965    let mut state = test_app_state("sloc_test_rate");
966    state.rate_limiter = Arc::new(IpRateLimiter::new(
967        Duration::from_mins(1),
968        2,
969        5,
970        Duration::from_secs(5),
971    ));
972    build_router(state)
973}
974
975/// Test router with a very tight auth lockout (threshold=2, window=200ms).
976/// Used by tests that need to trigger and verify the auth lockout response.
977pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
978    let mut state = test_app_state("sloc_test_auth_lockout");
979    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
980    state.rate_limiter = Arc::new(IpRateLimiter::new(
981        Duration::from_mins(1),
982        600,
983        2,                          // 2 failures triggers lockout
984        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
985    ));
986    build_router(state)
987}
988
989struct RuntimeSecurityConfig {
990    api_keys: Vec<secrecy::SecretBox<String>>,
991    readonly_api_keys: Vec<secrecy::SecretBox<String>>,
992    tls_cert: Option<String>,
993    tls_key: Option<String>,
994    tls_enabled: bool,
995    trust_proxy: bool,
996    trusted_proxy_ips: Vec<IpAddr>,
997    rate_limiter: Arc<IpRateLimiter>,
998}
999
1000/// Whether the operator has explicitly opted into running server mode with no API key.
1001/// This is the single escape hatch for the fail-closed server-mode auth requirement.
1002fn allow_unauthenticated_server_mode() -> bool {
1003    matches!(
1004        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1005        Ok("1") | Ok("true") | Ok("TRUE")
1006    )
1007}
1008
1009/// Fail-closed startup gate: refuse to launch a network-facing server that has no
1010/// authentication configured, unless the operator explicitly accepted the risk.
1011/// Desktop/local mode (`server_mode == false`) is always allowed.
1012fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1013    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1014}
1015
1016/// Umbrella strict-posture switch (`SLOC_HARDENED=1`). When set, opt-in hardening
1017/// defaults take effect: transport encryption is required on non-loopback binds and
1018/// the auth-lockout threshold tightens. Off by default so existing deployments are
1019/// unaffected; individual controls also keep their own env overrides.
1020fn hardened_mode() -> bool {
1021    std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1022}
1023
1024/// Whether a certificate must be present before serving a network-facing
1025/// (non-loopback) bind. Opt-in via `SLOC_REQUIRE_TLS=1` or `SLOC_HARDENED=1`. Off by
1026/// default, so cleartext and reverse-proxy-terminated deployments keep working.
1027fn require_tls() -> bool {
1028    hardened_mode()
1029        || std::env::var("SLOC_REQUIRE_TLS")
1030            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1031}
1032
1033/// Optional sliding idle timeout for authenticated sessions. `None` (the default)
1034/// means only the 8-hour absolute cap applies — identical to prior behaviour.
1035/// `SLOC_SESSION_IDLE_SECS=<n>` sets an explicit idle limit (`0` disables); under
1036/// `SLOC_HARDENED` it defaults to 15 minutes. Each authenticated request refreshes
1037/// the session's last-seen time, so the window slides.
1038pub(crate) fn session_idle_timeout() -> Option<Duration> {
1039    match std::env::var("SLOC_SESSION_IDLE_SECS")
1040        .ok()
1041        .and_then(|v| v.parse::<u64>().ok())
1042    {
1043        Some(0) => None,
1044        Some(secs) => Some(Duration::from_secs(secs)),
1045        None if hardened_mode() => Some(Duration::from_secs(15 * 60)),
1046        None => None,
1047    }
1048}
1049
1050/// Generic authorized-use notice shown when a banner is required but the operator
1051/// has not supplied custom text via `SLOC_CONSENT_BANNER`.
1052const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1053Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1054are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1055
1056/// The pre-access consent banner text, if enabled. `SLOC_CONSENT_BANNER=<text>`
1057/// sets custom wording; `SLOC_HARDENED` alone falls back to a generic notice.
1058/// `None` (the default) disables the banner entirely.
1059fn consent_banner_text() -> Option<String> {
1060    if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1061        let t = t.trim();
1062        if !t.is_empty() {
1063            return Some(t.to_owned());
1064        }
1065    }
1066    hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1067}
1068
1069/// True when this request is a top-level browser navigation that the consent gate
1070/// should intercept. APIs, assets, webhooks, health checks, and the accept
1071/// endpoint itself are never gated.
1072fn consent_gate_applies(req: &Request<Body>) -> bool {
1073    if !matches!(
1074        *req.method(),
1075        axum::http::Method::GET | axum::http::Method::HEAD
1076    ) {
1077        return false;
1078    }
1079    let is_html = req
1080        .headers()
1081        .get(header::ACCEPT)
1082        .and_then(|v| v.to_str().ok())
1083        .is_some_and(|a| a.contains("text/html"));
1084    if !is_html {
1085        return false;
1086    }
1087    let path = req.uri().path();
1088    const EXEMPT: &[&str] = &[
1089        "/auth/consent",
1090        "/static/",
1091        "/images/",
1092        "/assets/",
1093        "/badge/",
1094        "/healthz",
1095        "/api/",
1096        "/webhooks/",
1097        "/metrics",
1098        "/favicon",
1099        "/llms",
1100    ];
1101    !EXEMPT.iter().any(|p| path.starts_with(p))
1102}
1103
1104/// Whether the request already carries the consent acknowledgement cookie.
1105fn request_has_consent(req: &Request<Body>) -> bool {
1106    req.headers()
1107        .get(header::COOKIE)
1108        .and_then(|v| v.to_str().ok())
1109        .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1110}
1111
1112/// Pre-access consent gate. When a banner is configured, browser page navigations
1113/// must acknowledge it (recorded in a session cookie) before proceeding. A no-op
1114/// when unconfigured, so default deployments are unaffected.
1115async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1116    let Some(text) = consent_banner_text() else {
1117        return next.run(req).await;
1118    };
1119    if !consent_gate_applies(&req) || request_has_consent(&req) {
1120        return next.run(req).await;
1121    }
1122    let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1123    render_consent_page(&text, next_path)
1124}
1125
1126/// Minimal escaping for embedding operator/config text into the banner HTML.
1127fn html_escape_consent(s: &str) -> String {
1128    s.replace('&', "&amp;")
1129        .replace('<', "&lt;")
1130        .replace('>', "&gt;")
1131        .replace('"', "&quot;")
1132}
1133
1134/// Render the consent interstitial with an "I Agree" action that records
1135/// acknowledgement and returns the user to where they were headed.
1136fn render_consent_page(text: &str, next_path: &str) -> Response {
1137    // Only accept a safe same-origin relative path as the return target.
1138    let safe_next = if next_path.starts_with('/')
1139        && !next_path.starts_with("//")
1140        && !next_path.contains("://")
1141        && !next_path.starts_with("/auth/")
1142    {
1143        next_path
1144    } else {
1145        "/"
1146    };
1147    let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1148    let body = format!(
1149        r#"<!doctype html><html><head><meta charset="utf-8">
1150<meta name="viewport" content="width=device-width, initial-scale=1">
1151<title>Notice and Consent — OxideSLOC</title>
1152<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1153h1{{color:#b85d33;font-size:20px}}.notice{{line-height:1.65;background:#f7efe7;border:1px solid #e2d2c2;border-radius:10px;padding:18px 20px;white-space:pre-wrap}}
1154.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1155.agree:hover{{background:#a04d27}}</style>
1156</head><body>
1157<h1>Notice and Consent</h1>
1158<div class="notice">{}</div>
1159<a class="agree" href="{}">I Agree</a>
1160</body></html>"#,
1161        html_escape_consent(text),
1162        accept_url
1163    );
1164    (StatusCode::OK, Html(body)).into_response()
1165}
1166
1167/// Emit operator-facing warnings for insecure server-mode configurations.
1168/// Pure side-effect (stdout); no bearing on the returned config values.
1169fn emit_server_mode_warnings(
1170    server_mode: bool,
1171    api_keys_empty: bool,
1172    tls_enabled: bool,
1173    trust_proxy: bool,
1174    trusted_proxy_ips: &[IpAddr],
1175) {
1176    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1177        // Absence of a key is a hard startup failure in server mode (enforced by the
1178        // caller, `serve`). The only exception is an explicit operator opt-in via
1179        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
1180        println!(
1181            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1182             authentication. Every web endpoint is publicly reachable. Do NOT use this \
1183             outside a trusted, isolated network."
1184        );
1185    }
1186    if server_mode && !tls_enabled {
1187        println!(
1188            "WARNING: TLS is not configured. Traffic is cleartext. \
1189             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1190             or terminate TLS at a reverse proxy (nginx, caddy)."
1191        );
1192    }
1193    if server_mode {
1194        println!(
1195            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1196             to restrict cross-origin access (comma-separated)."
1197        );
1198    }
1199    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1200    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1201        println!(
1202            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1203             DISABLED for all git operations. Remove this variable before production use."
1204        );
1205    }
1206}
1207
1208/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
1209fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1210    if trust_proxy {
1211        if trusted_proxy_ips.is_empty() {
1212            println!(
1213                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1214                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1215                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1216            );
1217        } else {
1218            println!(
1219                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1220                trusted_proxy_ips
1221                    .iter()
1222                    .map(std::string::ToString::to_string)
1223                    .collect::<Vec<_>>()
1224                    .join(", ")
1225            );
1226        }
1227    } else if server_mode {
1228        println!(
1229            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1230             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1231             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1232             enable per-client rate limiting via X-Forwarded-For."
1233        );
1234    }
1235}
1236
1237fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1238    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1239        .or_else(|_| std::env::var("SLOC_API_KEY"))
1240        .unwrap_or_default()
1241        .split(',')
1242        .map(str::trim)
1243        .filter(|s| !s.is_empty())
1244        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1245        .collect();
1246    let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1247        std::env::var("SLOC_API_KEYS_READONLY")
1248            .unwrap_or_default()
1249            .split(',')
1250            .map(str::trim)
1251            .filter(|s| !s.is_empty())
1252            .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1253            .collect();
1254    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1255    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1256    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1257    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1258    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1259        .unwrap_or_default()
1260        .split(',')
1261        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1262        .collect();
1263    emit_server_mode_warnings(
1264        server_mode,
1265        api_keys.is_empty(),
1266        tls_enabled,
1267        trust_proxy,
1268        &trusted_proxy_ips,
1269    );
1270    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1271        .ok()
1272        .and_then(|v| v.parse::<u32>().ok())
1273        .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1274    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1275        .ok()
1276        .and_then(|v| v.parse::<u64>().ok())
1277        .unwrap_or(3600);
1278    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
1279    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
1280    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
1281    let default_rpm: usize = if server_mode { 120 } else { 600 };
1282    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1283        .ok()
1284        .and_then(|v| v.parse::<usize>().ok())
1285        .unwrap_or(default_rpm);
1286    let rate_limiter = Arc::new(IpRateLimiter::new(
1287        Duration::from_mins(1),
1288        rate_limit_rpm,
1289        auth_lockout_threshold,
1290        Duration::from_secs(auth_lockout_secs),
1291    ));
1292    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1293    RuntimeSecurityConfig {
1294        api_keys,
1295        readonly_api_keys,
1296        tls_cert,
1297        tls_key,
1298        tls_enabled,
1299        trust_proxy,
1300        trusted_proxy_ips,
1301        rate_limiter,
1302    }
1303}
1304
1305/// # Errors
1306///
1307/// Returns an error if the server fails to bind to the configured address or
1308/// if the TLS configuration cannot be loaded.
1309///
1310/// # Panics
1311///
1312/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
1313#[allow(clippy::too_many_lines)]
1314pub async fn serve(config: AppConfig) -> Result<()> {
1315    let bind_address = config.web.bind_address.clone();
1316    let server_mode = config.web.server_mode;
1317    let output_root = resolve_output_root(None);
1318    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
1319    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1320        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1321    let mut registry = ScanRegistry::load(&registry_path);
1322    registry.prune_stale();
1323    let _ = registry.save(&registry_path);
1324
1325    let sec = load_runtime_security_config(server_mode);
1326    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
1327    // launch with no API key would expose every endpoint publicly; fail closed unless the
1328    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
1329    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1330        audit::record(
1331            "server_start_refused",
1332            "denied",
1333            &[(
1334                "reason",
1335                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1336            )],
1337        );
1338        anyhow::bail!(
1339            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1340             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1341             unauthenticated server on a trusted, isolated network, explicitly set \
1342             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1343        );
1344    }
1345    if server_mode && sec.api_keys.is_empty() {
1346        audit::record("server_start_unauthenticated", "warning", &[]);
1347    }
1348    spawn_upload_staging_cleanup();
1349
1350    let git_clones_dir = resolve_git_clones_dir(&output_root);
1351    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1352        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1353    let schedules = ScheduleStore::load(&schedules_path);
1354    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1355        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1356    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1357    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1358        |_| output_root.join("confluence_config.json"),
1359        PathBuf::from,
1360    );
1361    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1362    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1363        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1364    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1365    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1366        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1367    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1368
1369    let state = AppState {
1370        base_config: config,
1371        artifacts: Arc::new(Mutex::new(HashMap::new())),
1372        async_runs: Arc::new(Mutex::new(HashMap::new())),
1373        registry: Arc::new(Mutex::new(registry)),
1374        registry_path,
1375        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1376        server_mode,
1377        allow_unauthenticated: allow_unauthenticated_server_mode(),
1378        tls_enabled: sec.tls_enabled,
1379        api_keys: Arc::new(sec.api_keys),
1380        readonly_api_keys: Arc::new(sec.readonly_api_keys),
1381        rate_limiter: sec.rate_limiter,
1382        trust_proxy: sec.trust_proxy,
1383        trusted_proxy_ips: sec.trusted_proxy_ips,
1384        git_clones_dir,
1385        schedules: Arc::new(Mutex::new(schedules)),
1386        schedules_path,
1387        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1388        scan_profiles_path,
1389        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1390        confluence: Arc::new(Mutex::new(confluence)),
1391        confluence_path,
1392        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1393        watched_dirs_path,
1394        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1395        cleanup_policy_path,
1396        cleanup_task_handle: Arc::new(Mutex::new(None)),
1397    };
1398
1399    restart_poll_schedules(&state).await;
1400    warn_insecure_gitlab_webhooks(&state).await;
1401
1402    // Restart auto-cleanup task if a policy was previously saved and is enabled.
1403    {
1404        let enabled = state
1405            .cleanup_policy
1406            .lock()
1407            .await
1408            .policy
1409            .as_ref()
1410            .is_some_and(|p| p.enabled);
1411        if enabled {
1412            let handle = spawn_cleanup_policy_task(state.clone());
1413            *state.cleanup_task_handle.lock().await = Some(handle);
1414        }
1415    }
1416
1417    let app = build_router(state.clone());
1418
1419    // Try the configured port first, then step up through a few alternatives.
1420    // On Windows, a killed process can leave its LISTEN socket as an unkillable
1421    // kernel zombie (visible in netstat but owned by no living process).  Rather
1422    // than failing, we auto-select the next free port and tell the user.
1423    let preferred: SocketAddr = bind_address
1424        .parse()
1425        .with_context(|| format!("invalid bind address: {bind_address}"))?;
1426
1427    // Opt-in transport-encryption gate: refuse to expose a network-facing (non-
1428    // loopback) listener in cleartext when TLS enforcement is requested. Off by
1429    // default; enable with SLOC_REQUIRE_TLS=1 or SLOC_HARDENED=1. Loopback binds
1430    // (including reverse-proxy-terminated setups) are always allowed.
1431    if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1432        audit::record(
1433            "server_start_refused",
1434            "denied",
1435            &[("reason", "TLS required for non-loopback bind")],
1436        );
1437        anyhow::bail!(
1438            "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1439             SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1440             a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1441        );
1442    }
1443
1444    let (listener, addr) = {
1445        let candidates = (0u16..=9).map(|offset| {
1446            let mut a = preferred;
1447            a.set_port(preferred.port().saturating_add(offset));
1448            a
1449        });
1450        let mut found = None;
1451        for candidate in candidates {
1452            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1453                found = Some((l, candidate));
1454                break;
1455            }
1456        }
1457        found.ok_or_else(|| {
1458            anyhow::anyhow!(
1459                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1460                bind_address,
1461                preferred.port(),
1462                preferred.port().saturating_add(9)
1463            )
1464        })?
1465    };
1466    if addr != preferred {
1467        eprintln!(
1468            "NOTE: port {} is blocked by a system socket (Windows zombie); \
1469             using {} instead.",
1470            preferred.port(),
1471            addr.port()
1472        );
1473    }
1474
1475    if sec.tls_enabled {
1476        let cert_path = sec
1477            .tls_cert
1478            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1479        let key_path = sec
1480            .tls_key
1481            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1482        let tls_config = build_tls_config(&cert_path, &key_path)
1483            .context("failed to load TLS certificate/key")?;
1484        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1485
1486        let url = format!("https://{addr}/");
1487        println!("OxideSLOC server running at {url} (TLS)");
1488        println!("Use Ctrl+C to stop.");
1489
1490        return serve_tls(listener, app, acceptor, server_mode).await;
1491    }
1492
1493    let url = format!("http://{addr}/");
1494    log_startup_url(&url, server_mode);
1495
1496    axum::serve(
1497        listener,
1498        app.into_make_service_with_connect_info::<SocketAddr>(),
1499    )
1500    .with_graceful_shutdown(shutdown_signal(server_mode))
1501    .await
1502    .context("web server terminated unexpectedly")
1503}
1504
1505/// Discover the primary non-loopback IPv4 address by asking the OS which
1506/// outbound interface it would use to reach a public address.  No packets are
1507/// sent — the UDP socket is only used to query the routing table.
1508fn primary_lan_ip() -> Option<String> {
1509    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1510    socket.connect("8.8.8.8:80").ok()?;
1511    let addr = socket.local_addr().ok()?;
1512    let ip = addr.ip();
1513    if ip.is_loopback() {
1514        return None;
1515    }
1516    Some(ip.to_string())
1517}
1518
1519/// Print the startup URL and, in local mode, open the browser and schedule it.
1520fn log_startup_url(url: &str, server_mode: bool) {
1521    if server_mode {
1522        println!("OxideSLOC server running at {url}");
1523        println!("Use Ctrl+C to stop.");
1524    } else {
1525        println!("OxideSLOC local web UI running at {url}");
1526        println!("Press Ctrl+C to stop the server.");
1527        let open_url = url.to_owned();
1528        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1529    }
1530}
1531
1532/// Open the given URL in the default system browser.
1533fn open_browser_tab(url: &str) {
1534    // Windows: invoke the URL protocol handler directly via rundll32 rather than
1535    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
1536    // first quoted token as a window title — both are fragile and shell-parsed. The
1537    // url.dll handler receives the URL as a single, non-shell argument.
1538    #[cfg(target_os = "windows")]
1539    let _ = std::process::Command::new("rundll32")
1540        .args(["url.dll,FileProtocolHandler", url])
1541        .stdout(Stdio::null())
1542        .stderr(Stdio::null())
1543        .spawn();
1544    #[cfg(target_os = "macos")]
1545    let _ = std::process::Command::new("open")
1546        .arg(url)
1547        .stdout(Stdio::null())
1548        .stderr(Stdio::null())
1549        .spawn();
1550    #[cfg(target_os = "linux")]
1551    let _ = std::process::Command::new("xdg-open")
1552        .arg(url)
1553        .stdout(Stdio::null())
1554        .stderr(Stdio::null())
1555        .spawn();
1556}
1557
1558/// Graceful-shutdown future: resolves on Ctrl-C.
1559async fn shutdown_signal(server_mode: bool) {
1560    if tokio::signal::ctrl_c().await.is_ok() {
1561        println!();
1562        if server_mode {
1563            println!("Shutting down OxideSLOC server...");
1564        } else {
1565            println!("Shutting down OxideSLOC local web UI...");
1566        }
1567        println!("Server stopped cleanly.");
1568    }
1569}
1570
1571/// Load a rustls `ServerConfig` from PEM certificate and key files.
1572fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1573    use rustls_pki_types::pem::PemObject;
1574    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1575
1576    let cert_bytes =
1577        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1578    let key_bytes =
1579        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1580
1581    let cert_chain: Vec<CertificateDer<'static>> =
1582        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1583            .collect::<std::result::Result<_, _>>()
1584            .context("failed to parse TLS certificates")?;
1585
1586    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1587        .context("failed to parse TLS private key")?;
1588
1589    // Explicitly pin the accepted protocol versions to TLS 1.2 and 1.3 (these are
1590    // rustls's safe defaults; stated here so the accepted set is auditable). rustls
1591    // ships only modern AEAD cipher suites — no CBC/RC4/3DES — so no suite pinning is
1592    // needed to exclude weak ciphers.
1593    let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1594        &rustls::version::TLS13,
1595        &rustls::version::TLS12,
1596    ]);
1597
1598    // Opt-in mutual TLS: when SLOC_TLS_CLIENT_CA points to a PEM CA bundle, require
1599    // every client to present a certificate that chains to it — a transport-layer
1600    // factor on top of the application API key. Unset = no client auth (prior
1601    // behaviour).
1602    let config = match client_cert_verifier()? {
1603        Some(verifier) => builder
1604            .with_client_cert_verifier(verifier)
1605            .with_single_cert(cert_chain, key),
1606        None => builder
1607            .with_no_client_auth()
1608            .with_single_cert(cert_chain, key),
1609    };
1610    config.context("failed to build TLS server config")
1611}
1612
1613/// Build a client-certificate verifier when `SLOC_TLS_CLIENT_CA` is configured,
1614/// enabling mutual TLS. Returns `None` (no client auth) when unset — the default.
1615fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1616    use rustls_pki_types::pem::PemObject;
1617    use rustls_pki_types::CertificateDer;
1618
1619    let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1620        .ok()
1621        .filter(|s| !s.is_empty())
1622    else {
1623        return Ok(None);
1624    };
1625    let ca_bytes = fs::read(&ca_path)
1626        .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1627    let mut roots = rustls::RootCertStore::empty();
1628    for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1629        let cert = cert.context("failed to parse client CA certificate")?;
1630        roots
1631            .add(cert)
1632            .context("failed to add client CA certificate to root store")?;
1633    }
1634    let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1635        .build()
1636        .context("failed to build client certificate verifier")?;
1637    Ok(Some(verifier))
1638}
1639
1640/// Accept loop with TLS termination using tokio-rustls + hyper-util.
1641async fn serve_tls(
1642    listener: tokio::net::TcpListener,
1643    app: Router,
1644    acceptor: tokio_rustls::TlsAcceptor,
1645    server_mode: bool,
1646) -> Result<()> {
1647    use hyper_util::rt::{TokioExecutor, TokioIo};
1648    use hyper_util::server::conn::auto::Builder as ConnBuilder;
1649    use hyper_util::service::TowerToHyperService;
1650    use tower::{Service, ServiceExt};
1651
1652    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1653
1654    loop {
1655        tokio::select! {
1656            biased;
1657            _ = tokio::signal::ctrl_c() => {
1658                println!();
1659                if server_mode {
1660                    println!("Shutting down OxideSLOC server...");
1661                } else {
1662                    println!("Shutting down OxideSLOC local web UI...");
1663                }
1664                println!("Server stopped cleanly.");
1665                return Ok(());
1666            }
1667            result = listener.accept() => {
1668                let (tcp, peer_addr) = result.context("TLS accept failed")?;
1669                let acceptor = acceptor.clone();
1670                let mut factory = make_svc.clone();
1671
1672                tokio::spawn(async move {
1673                    let tls = match acceptor.accept(tcp).await {
1674                        Ok(s) => s,
1675                        Err(e) => {
1676                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1677                            return;
1678                        }
1679                    };
1680                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1681                        Ok(f) => match Service::call(f, peer_addr).await {
1682                            Ok(s) => s,
1683                            Err(_) => return,
1684                        },
1685                        Err(_) => return,
1686                    };
1687                    let io = TokioIo::new(tls);
1688                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1689                        .serve_connection(io, TowerToHyperService::new(svc))
1690                        .await
1691                    {
1692                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1693                    }
1694                });
1695            }
1696        }
1697    }
1698}
1699
1700// auth moved to auth.rs
1701
1702fn build_cors_layer(server_mode: bool) -> CorsLayer {
1703    if server_mode {
1704        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1705            .unwrap_or_default()
1706            .split(',')
1707            .filter(|s| !s.is_empty())
1708            .filter_map(|s| s.trim().parse().ok())
1709            .collect();
1710        if allowed.is_empty() {
1711            return CorsLayer::new();
1712        }
1713        CorsLayer::new()
1714            .allow_origin(AllowOrigin::list(allowed))
1715            .allow_methods(AllowMethods::list([
1716                axum::http::Method::GET,
1717                axum::http::Method::POST,
1718            ]))
1719            .allow_headers(AllowHeaders::list([
1720                axum::http::header::AUTHORIZATION,
1721                axum::http::header::CONTENT_TYPE,
1722            ]))
1723    } else {
1724        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1725            let s = origin.to_str().unwrap_or("");
1726            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1727        }))
1728    }
1729}
1730
1731async fn add_security_headers(
1732    State(state): State<AppState>,
1733    mut req: Request<Body>,
1734    next: Next,
1735) -> Response {
1736    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1737    req.extensions_mut().insert(CspNonce(nonce.clone()));
1738    let mut resp = next.run(req).await;
1739    inject_page_fade_into_html(&mut resp, &nonce).await;
1740    let h = resp.headers_mut();
1741    // frame-ancestors defaults to deny (the UI cannot be iframed anywhere). An
1742    // operator can opt into embedding in named corporate dashboards by setting
1743    // SLOC_FRAME_ANCESTORS to a space-separated origin allowlist. X-Frame-Options
1744    // cannot express a multi-origin allowlist, so when one is configured we drop
1745    // XFO and let the CSP frame-ancestors directive govern (per-origin, and what
1746    // modern browsers honour); unset keeps the strict XFO: DENY + frame-ancestors
1747    // 'none' posture. A malformed value falls back to the safe default below.
1748    let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1749        .ok()
1750        .map(|v| v.trim().to_string())
1751        .filter(|v| !v.is_empty());
1752    if frame_ancestors.is_none() {
1753        h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1754    }
1755    let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1756    h.insert(
1757        "X-Content-Type-Options",
1758        HeaderValue::from_static("nosniff"),
1759    );
1760    h.insert(
1761        "Referrer-Policy",
1762        HeaderValue::from_static("strict-origin-when-cross-origin"),
1763    );
1764    let csp = format!(
1765        "default-src 'self'; \
1766         base-uri 'self'; \
1767         form-action 'self'; \
1768         style-src 'self' 'unsafe-inline'; \
1769         img-src 'self' data: blob:; \
1770         script-src 'self' 'nonce-{nonce}'; \
1771         font-src 'self' data:; \
1772         object-src 'none'; \
1773         frame-ancestors {frame_ancestors_directive}"
1774    );
1775    h.insert(
1776        "Content-Security-Policy",
1777        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1778            HeaderValue::from_static(
1779                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1780            )
1781        }),
1782    );
1783    h.insert(
1784        "X-Permitted-Cross-Domain-Policies",
1785        HeaderValue::from_static("none"),
1786    );
1787    h.insert(
1788        "Permissions-Policy",
1789        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1790    );
1791    h.insert(
1792        "Cross-Origin-Opener-Policy",
1793        HeaderValue::from_static("same-origin"),
1794    );
1795    h.insert(
1796        "Cross-Origin-Resource-Policy",
1797        HeaderValue::from_static("same-origin"),
1798    );
1799    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
1800    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
1801    h.insert(
1802        "Cross-Origin-Embedder-Policy",
1803        HeaderValue::from_static("require-corp"),
1804    );
1805    if state.tls_enabled {
1806        h.insert(
1807            "Strict-Transport-Security",
1808            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1809        );
1810    }
1811    resp
1812}
1813
1814/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
1815///
1816/// On state-changing methods, browser-driven cookie-authenticated requests must
1817/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
1818/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
1819///
1820/// Deliberately exempt:
1821/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
1822/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
1823///   ambient, so it is not CSRF-exploitable.
1824/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
1825/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
1826///   a browser performing a CSRF attack always sends `Origin`.
1827async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1828    use axum::http::Method;
1829
1830    let is_state_changing = matches!(
1831        *req.method(),
1832        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1833    );
1834    let path = req.uri().path();
1835    let has_token_auth = req.headers().contains_key("X-API-Key")
1836        || req
1837            .headers()
1838            .get(header::AUTHORIZATION)
1839            .and_then(|v| v.to_str().ok())
1840            .is_some_and(|v| v.starts_with("Bearer "));
1841
1842    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1843        return next.run(req).await;
1844    }
1845
1846    let headers = req.headers();
1847    let header_str = |name: &header::HeaderName| {
1848        headers
1849            .get(name)
1850            .and_then(|v| v.to_str().ok())
1851            .map(str::to_owned)
1852    };
1853    let origin = header_str(&header::ORIGIN);
1854    let referer = header_str(&header::REFERER);
1855    let host = header_str(&header::HOST);
1856
1857    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
1858    let authority_of = |url: &str| -> Option<String> {
1859        url.split_once("://")
1860            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1861    };
1862
1863    let source_authority = origin
1864        .as_deref()
1865        .and_then(authority_of)
1866        .or_else(|| referer.as_deref().and_then(authority_of));
1867
1868    match (source_authority, host) {
1869        // Neither Origin nor Referer present: treat as a non-browser client.
1870        (None, _) => next.run(req).await,
1871        (Some(src), Some(h)) if src == h => next.run(req).await,
1872        (Some(src), host) => {
1873            tracing::warn!(
1874                event = "csrf_rejected",
1875                path = %path,
1876                origin = %src,
1877                host = ?host,
1878                "Cross-origin state-changing request rejected (CSRF guard)"
1879            );
1880            (
1881                StatusCode::FORBIDDEN,
1882                "403 Forbidden — cross-origin request rejected\n",
1883            )
1884                .into_response()
1885        }
1886    }
1887}
1888
1889/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
1890/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
1891/// is overkill — a short opacity fade gives a smooth page-to-page transition
1892/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
1893/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
1894/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
1895/// light-mode flash for dark-theme users.
1896fn page_fade_html(nonce: &str) -> String {
1897    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
1898    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
1899    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
1900    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
1901    // class) keeps it invisible from the moment this style parses — at the top of <body> —
1902    // through the entire body parse, which reads as a delay before navigation "begins"
1903    // and then a blink. Without a fill-mode the animation starts at first paint and plays
1904    // 0 -> 1 cleanly, with no pre-paint hold.
1905    const STYLE: &str = r"<style>
1906@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1907.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1908body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1909@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1910</style>";
1911    // `dark`: apply the saved dark theme before paint to avoid a light flash.
1912    // The click handler gives immediate feedback by fading the *content* out the moment a
1913    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
1914    // preventDefault or delay navigation — the browser navigates instantly and the fade
1915    // plays opportunistically during the natural fetch window, so no latency is added.
1916    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
1917    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
1918    // if the click was actually a download (no unload) or the page is restored from bfcache.
1919    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');});})();";
1920    format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1921}
1922
1923/// Self-contained branded loading overlay for the heavy comparison pages (Scan
1924/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
1925/// `<script>` — meant to be spliced in immediately after `<body>`.
1926///
1927/// It pairs the spinner with a **visibility gate**: from the first byte the page
1928/// content is held at `visibility:hidden` (only the overlay paints), so the user
1929/// never sees a half-rendered flash while charts/tables are still settling. On
1930/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
1931/// still-opaque overlay, which then fades out one frame later — so the reveal is
1932/// of a finished page, with no glitch on either side of the transition.
1933///
1934/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
1935/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
1936/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
1937fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1938    const TPL: &str = r#"<style nonce="__N__">
1939html.sloc-pending body{visibility:hidden;}
1940html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1941#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%);}
1942#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1943body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1944body.pdf-mode #rpt-loading-overlay{display:none!important;}
1945.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1946.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;}
1947.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;}
1948@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1949@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1950body.dark-theme .rpt-bg-blob{opacity:.36;}
1951.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;}
1952@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1953body.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);}
1954.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1955.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1956.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1957.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));}
1958@keyframes rpt-spin{to{transform:rotate(360deg);}}
1959.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;}
1960body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1961body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1962.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1963.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;}
1964@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1965.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1966.rpt-dot:nth-child(2){animation-delay:.28s;}
1967.rpt-dot:nth-child(3){animation-delay:.56s;}
1968@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1969.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1970.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1971.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;}
1972body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1973@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;}}
1974</style>
1975<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1976<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>
1977<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1978  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1979  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1980  <div class="rpt-load-card">
1981    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1982    <div class="rpt-spinner-wrap">
1983      <div class="rpt-spinner-track"></div>
1984      <div class="rpt-spinner"></div>
1985      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1986    </div>
1987    <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>
1988    <div class="rpt-status" id="rpt-status">__LABEL__</div>
1989    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1990  </div>
1991</div>
1992<script nonce="__N__">
1993(function(){
1994  var ov=document.getElementById('rpt-loading-overlay');
1995  var root=document.documentElement;
1996  function reveal(){root.classList.remove('sloc-pending');}
1997  if(!ov){reveal();return;}
1998  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1999  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2000  var mi=0,prog=0,done=false,start=Date.now();
2001  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2002  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2003  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2004  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2005  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2006  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2007  setProg(8);
2008  var msgTimer=setInterval(nextMsg,700);
2009  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);
2010  // These pages draw charts into known SVG containers that start empty and are
2011  // filled by JS once layout is available (some only after a ResizeObserver pass
2012  // post-`load`). Treat the page as ready only once every chart container present
2013  // actually has rendered content, so the overlay never lifts on a half-drawn page.
2014  function chartsRendered(){
2015    var sel=['#cmp-tl-svg','#mc-chart'];
2016    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2017    return true;
2018  }
2019  function finish(){
2020    if(done)return;done=true;
2021    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2022    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2023    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2024    reveal();
2025    requestAnimationFrame(function(){requestAnimationFrame(function(){
2026      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2027    });});
2028  }
2029  // Wait for `load` (resources + first layout), then poll until the charts have
2030  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2031  function afterLoad(){
2032    var loadAt=Date.now();
2033    (function poll(){
2034      if(done)return;
2035      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2036        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2037        return;
2038      }
2039      requestAnimationFrame(poll);
2040    })();
2041  }
2042  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2043  // Absolute safety net: never let the gate/overlay get stuck.
2044  setTimeout(function(){if(!done)finish();},HARD_CAP);
2045})();
2046</script>"#;
2047    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2048}
2049
2050/// Shared toast-notification assets + a global PDF-export helper, spliced into
2051/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
2052/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
2053/// placed just before `</body>`.
2054///
2055/// It defines two globals:
2056/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
2057///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
2058///   toast stays up until its returned handle's `.dismiss()` is called.
2059/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
2060///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
2061///   `/export/pdf`, triggers the download, then raises a success or error toast and
2062///   restores the button. Centralising this guarantees identical, obvious feedback
2063///   everywhere instead of a silent `alert()`-only failure path.
2064fn sloc_toast_assets(nonce: &str) -> String {
2065    const TPL: &str = r#"<style nonce="__N__">
2066#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;}
2067.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);}
2068.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2069.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2070.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;}
2071.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2072.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2073.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2074.sloc-toast-success{border-color:#bfe0cc;}
2075.sloc-toast-error{border-color:#e6b3b3;}
2076.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2077.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;}
2078@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2079.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;}
2080.sloc-toast-x:hover{opacity:1;}
2081body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2082body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2083body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2084body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2085@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2086</style>
2087<script nonce="__N__">
2088(function(){
2089  if(window.slocToast)return;
2090  function wrap(){
2091    var w=document.getElementById('sloc-toast-wrap');
2092    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);}
2093    return w;
2094  }
2095  window.slocToast=function(msg,opts){
2096    opts=opts||{};
2097    var type=opts.type||'info';
2098    var loading=type==='loading';
2099    var t=document.createElement('div');
2100    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2101    t.setAttribute('role',type==='error'?'alert':'status');
2102    var ico=loading
2103      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2104      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2105    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2106    t.querySelector('.sloc-toast-msg').textContent=String(msg);
2107    wrap().appendChild(t);
2108    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2109    var gone=false,timer=null;
2110    function close(){
2111      if(gone)return;gone=true;if(timer)clearTimeout(timer);
2112      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2113      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2114    }
2115    t.querySelector('.sloc-toast-x').addEventListener('click',close);
2116    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2117    if(ttl>0)timer=setTimeout(close,ttl);
2118    return {dismiss:close,el:t};
2119  };
2120  window.slocExportPdf=function(o){
2121    o=o||{};
2122    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2123    if(btn&&btn.disabled)return;
2124    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2125    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2126    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2127      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2128      .then(function(blob){
2129        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2130        document.body.appendChild(a);a.click();document.body.removeChild(a);
2131        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2132        load.dismiss();
2133        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2134      })
2135      .catch(function(e){
2136        load.dismiss();
2137        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2138      })
2139      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2140  };
2141})();
2142</script>"#;
2143    TPL.replace("__N__", nonce)
2144}
2145
2146/// Buffer an HTML response body and splice the page fade-in right after the
2147/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
2148/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
2149/// branded loading spinner for slow renders).
2150async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2151    let is_html = resp
2152        .headers()
2153        .get(header::CONTENT_TYPE)
2154        .and_then(|v| v.to_str().ok())
2155        .is_some_and(|v| v.starts_with("text/html"));
2156    if !is_html {
2157        return;
2158    }
2159    let body = std::mem::replace(resp.body_mut(), Body::empty());
2160    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2161        return;
2162    };
2163    let html = match String::from_utf8(bytes.to_vec()) {
2164        Ok(s) => s,
2165        Err(e) => {
2166            *resp.body_mut() = Body::from(e.into_bytes());
2167            return;
2168        }
2169    };
2170    if html.contains("id=\"rpt-loading-overlay\"") {
2171        *resp.body_mut() = Body::from(html);
2172        return;
2173    }
2174    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
2175    // avoids allocating a lowercased copy of the whole document on every request.
2176    // Fall back to a case-insensitive scan only if that fails (rare/never).
2177    let insert_at = html
2178        .find("<body")
2179        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2180        .or_else(|| {
2181            let lower = html.to_ascii_lowercase();
2182            lower
2183                .find("<body")
2184                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2185        });
2186    let new_html = match insert_at {
2187        Some(at) => {
2188            let mut out = String::with_capacity(html.len() + 1024);
2189            out.push_str(&html[..at]);
2190            out.push_str(&page_fade_html(nonce));
2191            out.push_str(&html[at..]);
2192            out
2193        }
2194        None => html,
2195    };
2196    resp.headers_mut().remove(header::CONTENT_LENGTH);
2197    *resp.body_mut() = Body::from(new_html);
2198}
2199
2200async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2201    let peer_ip = req
2202        .extensions()
2203        .get::<axum::extract::ConnectInfo<SocketAddr>>()
2204        .map(|c| c.0.ip());
2205
2206    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
2207    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
2208    // header spoofing from direct connections.
2209    let ip = peer_ip
2210        .and_then(|peer| {
2211            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2212                req.headers()
2213                    .get("X-Forwarded-For")
2214                    .and_then(|v| v.to_str().ok())
2215                    .and_then(|s| s.split(',').next())
2216                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
2217            } else {
2218                None
2219            }
2220        })
2221        .or(peer_ip)
2222        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2223
2224    if !state.rate_limiter.is_allowed(ip) {
2225        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2226            path = %req.uri().path(), "Rate limit exceeded");
2227        return (
2228            StatusCode::TOO_MANY_REQUESTS,
2229            [(header::RETRY_AFTER, "60")],
2230            "429 Too Many Requests\n",
2231        )
2232            .into_response();
2233    }
2234    next.run(req).await
2235}
2236
2237async fn splash(
2238    State(state): State<AppState>,
2239    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2240) -> impl IntoResponse {
2241    let lan_ip = if state.server_mode {
2242        primary_lan_ip()
2243    } else {
2244        None
2245    };
2246    let port = state
2247        .base_config
2248        .web
2249        .bind_address
2250        .rsplit(':')
2251        .next()
2252        .and_then(|p| p.parse::<u16>().ok())
2253        .unwrap_or(4317);
2254    let has_api_key = !state.api_keys.is_empty();
2255    let template = SplashTemplate {
2256        csp_nonce,
2257        server_mode: state.server_mode,
2258        lan_ip,
2259        port,
2260        version: env!("CARGO_PKG_VERSION"),
2261        has_api_key,
2262    };
2263    Html(
2264        template
2265            .render()
2266            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2267    )
2268}
2269
2270async fn index(
2271    State(state): State<AppState>,
2272    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2273    Query(query): Query<IndexQuery>,
2274) -> impl IntoResponse {
2275    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2276        let policy = query
2277            .mixed_line_policy
2278            .unwrap_or_else(|| "code_only".to_string());
2279        let behavior = query
2280            .binary_file_behavior
2281            .unwrap_or_else(|| "skip".to_string());
2282        let cfg = ScanConfig {
2283            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2284            path: query.path.unwrap_or_default(),
2285            include_globs: query.include_globs.unwrap_or_default(),
2286            exclude_globs: query.exclude_globs.unwrap_or_default(),
2287            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2288            mixed_line_policy: policy,
2289            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2290                != Some("off"),
2291            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2292            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2293            vendor_directory_detection: query.vendor_directory_detection.as_deref()
2294                != Some("disabled"),
2295            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2296            binary_file_behavior: behavior,
2297            output_dir: query.output_dir.unwrap_or_default(),
2298            report_title: query.report_title.unwrap_or_default(),
2299            continuation_line_policy: query
2300                .continuation_line_policy
2301                .unwrap_or_else(default_each_physical_line),
2302            blank_in_block_comment_policy: query
2303                .blank_in_block_comment_policy
2304                .unwrap_or_else(default_count_as_comment),
2305            count_compiler_directives: query.count_compiler_directives.as_deref()
2306                != Some("disabled"),
2307            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2308            style_col_threshold: query
2309                .style_col_threshold
2310                .as_deref()
2311                .and_then(|s| s.parse().ok())
2312                .unwrap_or(80),
2313            style_score_threshold: query
2314                .style_score_threshold
2315                .as_deref()
2316                .and_then(|s| s.parse().ok())
2317                .unwrap_or(0),
2318            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2319            coverage_file: query.coverage_file.unwrap_or_default(),
2320            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2321            complexity_alert: query
2322                .complexity_alert
2323                .as_deref()
2324                .and_then(|s| s.parse().ok())
2325                .unwrap_or(0),
2326            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2327            activity_window: query
2328                .activity_window
2329                .as_deref()
2330                .and_then(|s| s.parse().ok())
2331                .unwrap_or(90),
2332        };
2333        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2334    } else {
2335        "{}".to_string()
2336    };
2337
2338    let git_repo = query.git_repo.unwrap_or_default();
2339    let git_ref = query.git_ref.unwrap_or_default();
2340
2341    let git_label = make_git_label(&git_repo, &git_ref);
2342    let git_output_dir = if git_label.is_empty() {
2343        String::new()
2344    } else {
2345        desktop_dir().join(&git_label).display().to_string()
2346    };
2347    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2348    let git_output_dir_json =
2349        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2350
2351    let template = IndexTemplate {
2352        version: env!("CARGO_PKG_VERSION"),
2353        prefill_json,
2354        csp_nonce,
2355        git_repo,
2356        git_ref,
2357        git_label_json,
2358        git_output_dir_json,
2359        server_mode: state.server_mode,
2360    };
2361
2362    Html(
2363        template
2364            .render()
2365            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2366    )
2367}
2368
2369async fn scan_setup_handler(
2370    State(state): State<AppState>,
2371    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2372) -> impl IntoResponse {
2373    let recent_scans_json = {
2374        let arr: Vec<serde_json::Value> = {
2375            let reg = state.registry.lock().await;
2376            reg.entries
2377                .iter()
2378                .rev()
2379                .take(6)
2380                .map(|e| {
2381                    let run_dir = e
2382                        .html_path
2383                        .as_ref()
2384                        .or(e.json_path.as_ref())
2385                        .and_then(|p| p.parent().map(PathBuf::from));
2386                    let config_val: Option<serde_json::Value> = run_dir
2387                        .and_then(|d| find_scan_config_in_dir(&d))
2388                        .and_then(|p| fs::read_to_string(&p).ok())
2389                        .and_then(|s| serde_json::from_str(&s).ok());
2390                    serde_json::json!({
2391                        "project_label": e.project_label,
2392                        "timestamp": fmt_la_time(e.timestamp_utc),
2393                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2394                        "config": config_val,
2395                    })
2396                })
2397                .collect()
2398        };
2399        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2400    };
2401
2402    let template = ScanSetupTemplate {
2403        version: env!("CARGO_PKG_VERSION"),
2404        recent_scans_json,
2405        csp_nonce,
2406    };
2407    Html(
2408        template
2409            .render()
2410            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2411    )
2412}
2413
2414async fn healthz() -> &'static str {
2415    "ok"
2416}
2417
2418async fn api_version_handler() -> impl IntoResponse {
2419    axum::Json(serde_json::json!({
2420        "name": "oxide-sloc",
2421        "version": env!("CARGO_PKG_VERSION"),
2422    }))
2423}
2424
2425// ── Prometheus metrics ────────────────────────────────────────────────────────
2426
2427fn prom_runs_total() -> &'static prometheus::IntCounter {
2428    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2429    COUNTER.get_or_init(|| {
2430        prometheus::register_int_counter!(
2431            "oxide_sloc_runs_total",
2432            "Total number of completed analysis runs"
2433        )
2434        .expect("failed to register oxide_sloc_runs_total counter")
2435    })
2436}
2437
2438async fn metrics_handler() -> impl IntoResponse {
2439    use prometheus::Encoder as _;
2440    let mut buf = Vec::new();
2441    let encoder = prometheus::TextEncoder::new();
2442    let _ = encoder.encode(&prometheus::gather(), &mut buf);
2443    (
2444        [(
2445            axum::http::header::CONTENT_TYPE,
2446            "text/plain; version=0.0.4; charset=utf-8",
2447        )],
2448        buf,
2449    )
2450}
2451
2452static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2453
2454async fn openapi_yaml_handler() -> impl IntoResponse {
2455    (
2456        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2457        OPENAPI_YAML,
2458    )
2459}
2460
2461static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2462static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2463
2464async fn llms_txt_handler() -> impl IntoResponse {
2465    (
2466        [
2467            (
2468                axum::http::header::CONTENT_TYPE,
2469                "text/plain; charset=utf-8",
2470            ),
2471            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2472        ],
2473        LLMS_TXT,
2474    )
2475}
2476
2477async fn llms_full_txt_handler() -> impl IntoResponse {
2478    (
2479        [
2480            (
2481                axum::http::header::CONTENT_TYPE,
2482                "text/plain; charset=utf-8",
2483            ),
2484            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2485        ],
2486        LLMS_FULL_TXT,
2487    )
2488}
2489
2490async fn api_docs_handler(
2491    State(state): State<AppState>,
2492    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2493) -> impl IntoResponse {
2494    let has_api_key = !state.api_keys.is_empty();
2495    Html(
2496        ApiDocsTemplate {
2497            has_api_key,
2498            csp_nonce,
2499            version: env!("CARGO_PKG_VERSION"),
2500        }
2501        .render()
2502        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2503    )
2504}
2505
2506async fn chart_js_handler() -> impl IntoResponse {
2507    (
2508        [
2509            (
2510                header::CONTENT_TYPE,
2511                "application/javascript; charset=utf-8",
2512            ),
2513            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2514        ],
2515        CHART_JS,
2516    )
2517}
2518
2519async fn report_chart_js_handler() -> impl IntoResponse {
2520    (
2521        [
2522            (
2523                header::CONTENT_TYPE,
2524                "application/javascript; charset=utf-8",
2525            ),
2526            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2527        ],
2528        REPORT_CHART_JS,
2529    )
2530}
2531
2532#[derive(Debug, Deserialize)]
2533struct AnalyzeForm {
2534    path: String,
2535    git_repo: Option<String>,
2536    git_ref: Option<String>,
2537    mixed_line_policy: Option<MixedLinePolicy>,
2538    python_docstrings_as_comments: Option<String>,
2539    generated_file_detection: Option<String>,
2540    minified_file_detection: Option<String>,
2541    vendor_directory_detection: Option<String>,
2542    include_lockfiles: Option<String>,
2543    binary_file_behavior: Option<BinaryFileBehavior>,
2544    output_dir: Option<String>,
2545    report_title: Option<String>,
2546    report_header_footer: Option<String>,
2547    include_globs: Option<String>,
2548    exclude_globs: Option<String>,
2549    submodule_breakdown: Option<String>,
2550    coverage_file: Option<String>,
2551    continuation_line_policy: Option<ContinuationLinePolicy>,
2552    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2553    count_compiler_directives: Option<String>,
2554    style_col_threshold: Option<String>,
2555    style_analysis_enabled: Option<String>,
2556    style_score_threshold: Option<String>,
2557    style_lang_scope: Option<String>,
2558    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
2559    cocomo_mode: Option<String>,
2560    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
2561    complexity_alert: Option<String>,
2562    /// Whether to exclude duplicate files from displayed SLOC totals.
2563    exclude_duplicates: Option<String>,
2564    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
2565    activity_window: Option<String>,
2566}
2567
2568#[allow(clippy::struct_excessive_bools)]
2569#[derive(Debug, Serialize, Deserialize, Clone)]
2570struct ScanConfig {
2571    oxide_sloc_version: String,
2572    path: String,
2573    include_globs: String,
2574    exclude_globs: String,
2575    submodule_breakdown: bool,
2576    mixed_line_policy: String,
2577    python_docstrings_as_comments: bool,
2578    generated_file_detection: bool,
2579    minified_file_detection: bool,
2580    vendor_directory_detection: bool,
2581    include_lockfiles: bool,
2582    binary_file_behavior: String,
2583    output_dir: String,
2584    report_title: String,
2585    // IEEE 1045-1992 and advanced fields added in later release
2586    #[serde(default = "default_each_physical_line")]
2587    continuation_line_policy: String,
2588    #[serde(default = "default_count_as_comment")]
2589    blank_in_block_comment_policy: String,
2590    #[serde(default = "default_true_bool")]
2591    count_compiler_directives: bool,
2592    #[serde(default = "default_true_bool")]
2593    style_analysis_enabled: bool,
2594    #[serde(default = "default_style_col_threshold")]
2595    style_col_threshold: u16,
2596    #[serde(default)]
2597    style_score_threshold: u8,
2598    #[serde(default = "default_all_scope")]
2599    style_lang_scope: String,
2600    #[serde(default)]
2601    coverage_file: String,
2602    #[serde(default = "default_organic")]
2603    cocomo_mode: String,
2604    #[serde(default)]
2605    complexity_alert: u32,
2606    #[serde(default)]
2607    exclude_duplicates: bool,
2608    /// Git hotspots activity window in days (on by default; 0 = disabled).
2609    #[serde(default = "default_activity_window")]
2610    activity_window: u32,
2611}
2612
2613const fn default_activity_window() -> u32 {
2614    90
2615}
2616
2617fn default_each_physical_line() -> String {
2618    "each_physical_line".to_string()
2619}
2620fn default_count_as_comment() -> String {
2621    "count_as_comment".to_string()
2622}
2623const fn default_true_bool() -> bool {
2624    true
2625}
2626const fn default_style_col_threshold() -> u16 {
2627    80
2628}
2629fn default_all_scope() -> String {
2630    "all".to_string()
2631}
2632fn default_organic() -> String {
2633    "organic".to_string()
2634}
2635
2636#[derive(Debug, Deserialize, Default)]
2637struct IndexQuery {
2638    path: Option<String>,
2639    include_globs: Option<String>,
2640    exclude_globs: Option<String>,
2641    submodule_breakdown: Option<String>,
2642    mixed_line_policy: Option<String>,
2643    python_docstrings_as_comments: Option<String>,
2644    generated_file_detection: Option<String>,
2645    minified_file_detection: Option<String>,
2646    vendor_directory_detection: Option<String>,
2647    include_lockfiles: Option<String>,
2648    binary_file_behavior: Option<String>,
2649    output_dir: Option<String>,
2650    report_title: Option<String>,
2651    prefilled: Option<String>,
2652    git_repo: Option<String>,
2653    git_ref: Option<String>,
2654    // IEEE 1045-1992 and advanced fields
2655    continuation_line_policy: Option<String>,
2656    blank_in_block_comment_policy: Option<String>,
2657    count_compiler_directives: Option<String>,
2658    style_analysis_enabled: Option<String>,
2659    style_col_threshold: Option<String>,
2660    style_score_threshold: Option<String>,
2661    style_lang_scope: Option<String>,
2662    coverage_file: Option<String>,
2663    cocomo_mode: Option<String>,
2664    complexity_alert: Option<String>,
2665    exclude_duplicates: Option<String>,
2666    activity_window: Option<String>,
2667}
2668
2669#[derive(Debug, Deserialize)]
2670struct PreviewQuery {
2671    path: Option<String>,
2672    include_globs: Option<String>,
2673    exclude_globs: Option<String>,
2674}
2675
2676#[cfg(feature = "native-dialog")]
2677#[derive(Debug, Deserialize)]
2678struct PickDirectoryQuery {
2679    kind: Option<String>,
2680    current: Option<String>,
2681}
2682
2683#[cfg(not(feature = "native-dialog"))]
2684#[derive(Debug, Deserialize)]
2685struct PickDirectoryQuery {}
2686
2687#[derive(Debug, Deserialize, Default)]
2688struct ArtifactQuery {
2689    download: Option<String>,
2690}
2691
2692#[cfg(feature = "native-dialog")]
2693#[derive(Debug, Serialize)]
2694struct PickDirectoryResponse {
2695    selected_path: Option<String>,
2696    cancelled: bool,
2697}
2698
2699#[cfg(feature = "native-dialog")]
2700async fn pick_directory_handler(
2701    State(state): State<AppState>,
2702    Query(query): Query<PickDirectoryQuery>,
2703) -> Response {
2704    if state.server_mode {
2705        return StatusCode::NOT_FOUND.into_response();
2706    }
2707    // Return immediately without opening a dialog in headless / CI environments.
2708    if std::env::var("SLOC_HEADLESS").is_ok() {
2709        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2710            .into_response();
2711    }
2712
2713    let is_coverage = query.kind.as_deref() == Some("coverage");
2714    let title = match query.kind.as_deref() {
2715        Some("output") => "Select output directory",
2716        Some("reports") => "Select folder containing saved reports",
2717        Some("coverage") => "Select LCOV coverage file",
2718        _ => "Select project directory",
2719    }
2720    .to_owned();
2721    let current = query.current.clone();
2722
2723    let picked = tokio::task::spawn_blocking(move || {
2724        // Windows: attach to the foreground thread so the dialog inherits focus,
2725        // and kick off a watcher that flashes the dialog once it appears.
2726        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2727        let fg_tid = win_dialog_focus::attach_to_foreground();
2728        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2729        win_dialog_focus::flash_dialog_when_ready(title.clone());
2730
2731        let mut dialog = rfd::FileDialog::new().set_title(&title);
2732        if let Some(current) = current.as_deref() {
2733            let resolved = resolve_input_path(current);
2734            let seed = if resolved.is_dir() {
2735                Some(resolved)
2736            } else {
2737                resolved.parent().map(Path::to_path_buf)
2738            };
2739            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2740                dialog = dialog.set_directory(seed_dir);
2741            }
2742        }
2743        let result = if is_coverage {
2744            dialog
2745                .add_filter(
2746                    "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2747                    &["info", "lcov", "xml", "json"],
2748                )
2749                .pick_file()
2750        } else {
2751            dialog.pick_folder()
2752        };
2753
2754        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2755        win_dialog_focus::detach_from_foreground(fg_tid);
2756
2757        result
2758    })
2759    .await
2760    .unwrap_or(None);
2761
2762    Json(PickDirectoryResponse {
2763        selected_path: picked.as_ref().map(|p| display_path(p)),
2764        cancelled: picked.is_none(),
2765    })
2766    .into_response()
2767}
2768
2769#[cfg(not(feature = "native-dialog"))]
2770async fn pick_directory_handler(
2771    State(_state): State<AppState>,
2772    Query(_query): Query<PickDirectoryQuery>,
2773) -> Response {
2774    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2775}
2776
2777#[cfg(feature = "native-dialog")]
2778async fn pick_file_handler(State(state): State<AppState>) -> Response {
2779    if state.server_mode {
2780        return StatusCode::NOT_FOUND.into_response();
2781    }
2782    if std::env::var("SLOC_HEADLESS").is_ok() {
2783        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2784            .into_response();
2785    }
2786    let picked = tokio::task::spawn_blocking(|| {
2787        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2788        let fg_tid = win_dialog_focus::attach_to_foreground();
2789        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2790        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2791
2792        let result = rfd::FileDialog::new()
2793            .set_title("Select HTML report")
2794            .add_filter("HTML report", &["html"])
2795            .pick_file();
2796
2797        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2798        win_dialog_focus::detach_from_foreground(fg_tid);
2799
2800        result
2801    })
2802    .await
2803    .unwrap_or(None);
2804    Json(PickDirectoryResponse {
2805        selected_path: picked.as_ref().map(|p| display_path(p)),
2806        cancelled: picked.is_none(),
2807    })
2808    .into_response()
2809}
2810
2811#[cfg(not(feature = "native-dialog"))]
2812async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2813    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2814}
2815
2816// ── Browser-upload handlers (server mode only) ────────────────────────────────
2817
2818/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
2819/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
2820fn is_upload_tmp_path(path: &Path) -> bool {
2821    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2822    path.starts_with(&upload_root)
2823}
2824
2825/// Returns true when `path` is the built-in sample or test-fixture directory.
2826/// These paths ship with the server binary and are always safe to scan/preview.
2827fn is_sample_path(path: &Path) -> bool {
2828    let root = workspace_root();
2829    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2830}
2831
2832/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
2833fn upload_base_dir() -> PathBuf {
2834    std::env::temp_dir().join("oxide-sloc-uploads")
2835}
2836
2837/// Returns the staging path for a given upload id inside the base dir.
2838fn upload_staging_path(id: &str) -> PathBuf {
2839    upload_base_dir().join(id)
2840}
2841
2842/// Validate basic field constraints on a directory-upload request.
2843/// Returns an error `Response` if the request should be rejected immediately.
2844#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
2845fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2846    const MAX_FILES: usize = 50_000;
2847    if body.files.is_empty() {
2848        return Err((
2849            StatusCode::BAD_REQUEST,
2850            Json(serde_json::json!({"error": "No files received"})),
2851        )
2852            .into_response());
2853    }
2854    if body.files.len() > MAX_FILES {
2855        return Err((
2856            StatusCode::PAYLOAD_TOO_LARGE,
2857            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2858        )
2859            .into_response());
2860    }
2861    Ok(())
2862}
2863
2864/// Resolve or create the staging directory for a directory upload.
2865/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
2866fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2867    match id {
2868        Some(id)
2869            if !id.is_empty()
2870                && id.len() <= 36
2871                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2872        {
2873            (id.to_string(), upload_staging_path(id))
2874        }
2875        _ => {
2876            let new_id = uuid::Uuid::new_v4().to_string();
2877            let staging = upload_staging_path(&new_id);
2878            (new_id, staging)
2879        }
2880    }
2881}
2882
2883/// Decode, size-check, and write one uploaded file entry into `staging`.
2884/// Returns `Ok(())` whether the file was written or skipped (bad base64).
2885/// Returns `Err(Response)` for fatal errors; the caller is responsible for
2886/// cleaning up `staging` before propagating the error.
2887#[allow(clippy::result_large_err)]
2888async fn stage_decoded_entry(
2889    entry: &UploadedFile,
2890    staging: &Path,
2891    total_bytes: &mut usize,
2892    project_root: &mut Option<PathBuf>,
2893) -> Result<(), Response> {
2894    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2895
2896    let Ok(data) = base64::Engine::decode(
2897        &base64::engine::general_purpose::STANDARD,
2898        entry.content.as_bytes(),
2899    ) else {
2900        return Ok(());
2901    };
2902
2903    *total_bytes += data.len();
2904    if *total_bytes > MAX_TOTAL_BYTES {
2905        return Err((
2906            StatusCode::PAYLOAD_TOO_LARGE,
2907            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2908        )
2909            .into_response());
2910    }
2911
2912    let rel = std::path::Path::new(&entry.path);
2913    if project_root.is_none() {
2914        if let Some(first) = rel.components().next() {
2915            *project_root = Some(staging.join(first.as_os_str()));
2916        }
2917    }
2918
2919    let dest = staging.join(rel);
2920    if let Some(parent) = dest.parent() {
2921        if tokio::fs::create_dir_all(parent).await.is_err() {
2922            return Err((
2923                StatusCode::INTERNAL_SERVER_ERROR,
2924                Json(serde_json::json!({"error": "Failed to create directory structure"})),
2925            )
2926                .into_response());
2927        }
2928    }
2929
2930    if tokio::fs::write(&dest, &data).await.is_err() {
2931        return Err((
2932            StatusCode::INTERNAL_SERVER_ERROR,
2933            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2934        )
2935            .into_response());
2936    }
2937
2938    Ok(())
2939}
2940
2941/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
2942/// and path-traversal guard. Returns `(file_count, project_root)` on success or
2943/// an error `Response` on failure (staging dir is cleaned up before returning).
2944async fn write_upload_files(
2945    files: &[UploadedFile],
2946    staging: &Path,
2947    upload_id: &str,
2948) -> Result<(usize, Option<PathBuf>), Response> {
2949    let mut total_bytes: usize = 0;
2950    let mut project_root: Option<PathBuf> = None;
2951
2952    for entry in files {
2953        let rel = std::path::Path::new(&entry.path);
2954        if rel
2955            .components()
2956            .any(|c| matches!(c, std::path::Component::ParentDir))
2957        {
2958            // Reject the entire upload on the first path traversal attempt.
2959            let _ = tokio::fs::remove_dir_all(staging).await;
2960            tracing::warn!(
2961                event = "upload_path_traversal",
2962                upload_id = %upload_id,
2963                path = %entry.path,
2964                "Upload rejected: path traversal component detected"
2965            );
2966            return Err((
2967                StatusCode::BAD_REQUEST,
2968                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2969            )
2970                .into_response());
2971        }
2972
2973        if let Err(resp) =
2974            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2975        {
2976            let _ = tokio::fs::remove_dir_all(staging).await;
2977            return Err(resp);
2978        }
2979    }
2980
2981    Ok((files.len(), project_root))
2982}
2983
2984/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
2985/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
2986fn parse_tarball_size_caps() -> (u64, u64) {
2987    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2988        .ok()
2989        .and_then(|v| v.parse().ok())
2990        .unwrap_or(2048_u64)
2991        * 1024
2992        * 1024;
2993    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2994        .ok()
2995        .and_then(|v| v.parse().ok())
2996        .unwrap_or(10_240_u64)
2997        * 1024
2998        * 1024;
2999    (compressed, decompressed)
3000}
3001
3002/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
3003/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
3004/// are rejected before the streaming handler is invoked.
3005fn tarball_http_body_limit_bytes() -> usize {
3006    std::env::var("SLOC_MAX_TARBALL_MB")
3007        .ok()
3008        .and_then(|v| v.parse::<usize>().ok())
3009        .unwrap_or(2048)
3010        .saturating_mul(1024 * 1024)
3011}
3012
3013/// Stream `body` into `dest_path`, enforcing `max_bytes`.
3014/// Returns the number of compressed bytes written, or an error `Response`.
3015/// Cleans up `dest_path` on error.
3016#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3017async fn stream_body_to_file(
3018    body: axum::body::Body,
3019    dest_path: &Path,
3020    max_bytes: u64,
3021) -> Result<u64, Response> {
3022    use http_body_util::BodyExt as _;
3023    use tokio::io::AsyncWriteExt as _;
3024
3025    let mut file = match tokio::fs::File::create(dest_path).await {
3026        Ok(f) => f,
3027        Err(e) => {
3028            tracing::error!(
3029                event = "upload_io_error",
3030                "failed to create tarball temp file: {e}"
3031            );
3032            return Err((
3033                StatusCode::INTERNAL_SERVER_ERROR,
3034                Json(serde_json::json!({"error": "Upload initialization failed"})),
3035            )
3036                .into_response());
3037        }
3038    };
3039
3040    let mut body = body;
3041    let mut written: u64 = 0;
3042    loop {
3043        match body.frame().await {
3044            None => break,
3045            Some(Err(e)) => {
3046                let _ = tokio::fs::remove_file(dest_path).await;
3047                return Err((
3048                    StatusCode::BAD_REQUEST,
3049                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3050                )
3051                    .into_response());
3052            }
3053            Some(Ok(frame)) => {
3054                if let Ok(data) = frame.into_data() {
3055                    written += data.len() as u64;
3056                    if written > max_bytes {
3057                        let _ = tokio::fs::remove_file(dest_path).await;
3058                        return Err((
3059                            StatusCode::PAYLOAD_TOO_LARGE,
3060                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3061                        )
3062                            .into_response());
3063                    }
3064                    if let Err(e) = file.write_all(&data).await {
3065                        let _ = tokio::fs::remove_file(dest_path).await;
3066                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3067                        return Err((
3068                            StatusCode::INTERNAL_SERVER_ERROR,
3069                            Json(serde_json::json!({"error": "Upload write failed"})),
3070                        )
3071                            .into_response());
3072                    }
3073                }
3074            }
3075        }
3076    }
3077    drop(file);
3078    Ok(written)
3079}
3080
3081/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
3082/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
3083/// on failure (staging dir is cleaned up before returning).
3084#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
3085async fn extract_tarball_to_staging(
3086    tarball_path: &Path,
3087    staging: &Path,
3088    max_decompressed_bytes: u64,
3089) -> Result<(), Response> {
3090    let staging_clone = staging.to_path_buf();
3091    let tarball_clone = tarball_path.to_path_buf();
3092    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3093        let file = std::fs::File::open(&tarball_clone)?;
3094        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3095        let limited = SizeLimitReader {
3096            inner: gz,
3097            remaining: max_decompressed_bytes,
3098        };
3099        let mut archive = tar::Archive::new(limited);
3100        archive.set_overwrite(true);
3101        archive.set_preserve_permissions(false);
3102        std::fs::create_dir_all(&staging_clone)?;
3103        archive.unpack(&staging_clone)?;
3104        Ok(())
3105    })
3106    .await;
3107    let _ = tokio::fs::remove_file(tarball_path).await;
3108
3109    match extract_result {
3110        Ok(Ok(())) => Ok(()),
3111        Ok(Err(e)) => {
3112            let _ = tokio::fs::remove_dir_all(staging).await;
3113            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3114            tracing::warn!(
3115                event = "upload_extract_error",
3116                "tarball extraction failed: {e:#}"
3117            );
3118            let (status, msg) = if is_size_limit {
3119                (
3120                    StatusCode::PAYLOAD_TOO_LARGE,
3121                    "Archive exceeds the decompressed size limit",
3122                )
3123            } else {
3124                (StatusCode::BAD_REQUEST, "Failed to extract archive")
3125            };
3126            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3127        }
3128        Err(e) => {
3129            let _ = tokio::fs::remove_dir_all(staging).await;
3130            tracing::error!(
3131                event = "upload_extract_panic",
3132                "tarball extraction task panicked: {e}"
3133            );
3134            Err((
3135                StatusCode::INTERNAL_SERVER_ERROR,
3136                Json(serde_json::json!({"error": "Archive extraction failed"})),
3137            )
3138                .into_response())
3139        }
3140    }
3141}
3142
3143/// If `staging` contains exactly one top-level directory, return its path
3144/// (the common case when the archive was created with `webkitRelativePath`).
3145/// Otherwise return `None`.
3146async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3147    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3148    let first = entries.next_entry().await.ok()??;
3149    if !first.path().is_dir() {
3150        return None;
3151    }
3152    if entries.next_entry().await.unwrap_or(None).is_some() {
3153        return None;
3154    }
3155    Some(first.path())
3156}
3157
3158/// Request body for `POST /api/upload-directory`.
3159///
3160/// Each entry carries a relative path (identical to the browser's
3161/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
3162/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
3163/// avoids pulling in a `multipart` library that is not in the vendor archive.
3164#[derive(Deserialize)]
3165struct UploadDirRequest {
3166    files: Vec<UploadedFile>,
3167    /// If provided, append this batch to an existing upload session instead of
3168    /// creating a new staging directory. Must be a plain UUID (no path separators).
3169    upload_id: Option<String>,
3170}
3171
3172#[derive(Deserialize)]
3173struct UploadedFile {
3174    /// `webkitRelativePath` value from the browser File object.
3175    path: String,
3176    /// Raw file bytes encoded as standard base64.
3177    content: String,
3178}
3179
3180/// POST /api/upload-directory
3181///
3182/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
3183/// Saves all files to a temp staging directory preserving their relative paths,
3184/// then returns the server-side root directory path so the caller can populate
3185/// the scan-path field and run a normal analysis.
3186///
3187/// Only available in server mode; returns 404 in local mode (use the native
3188/// rfd dialog instead).
3189async fn upload_directory_handler(
3190    State(state): State<AppState>,
3191    Json(body): Json<UploadDirRequest>,
3192) -> Response {
3193    if !state.server_mode {
3194        return StatusCode::NOT_FOUND.into_response();
3195    }
3196    if let Err(resp) = validate_upload_dir_request(&body) {
3197        return resp;
3198    }
3199    // Reuse an existing staging dir when the client sends a continuation batch,
3200    // otherwise create a fresh one. Validate the id to prevent path traversal.
3201    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3202    match write_upload_files(&body.files, &staging, &upload_id).await {
3203        Ok((file_count, project_root)) => {
3204            let scan_root = project_root.unwrap_or_else(|| staging.clone());
3205            Json(serde_json::json!({
3206                "tmp_path": scan_root.to_string_lossy(),
3207                "file_count": file_count,
3208                "upload_id": upload_id.clone()
3209            }))
3210            .into_response()
3211        }
3212        Err(resp) => resp,
3213    }
3214}
3215
3216/// Request body for `POST /api/upload-file`.
3217#[derive(Deserialize)]
3218struct UploadFileRequest {
3219    /// Original filename (used only to preserve the extension).
3220    filename: String,
3221    /// File bytes encoded as standard base64.
3222    content: String,
3223}
3224
3225/// POST /api/upload-file
3226///
3227/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
3228/// Accepts `{ "filename": "…", "content": "<base64>" }`.
3229/// Only available in server mode.
3230async fn upload_file_handler(
3231    State(state): State<AppState>,
3232    Json(body): Json<UploadFileRequest>,
3233) -> Response {
3234    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
3235
3236    if !state.server_mode {
3237        return StatusCode::NOT_FOUND.into_response();
3238    }
3239
3240    let Ok(data) = base64::Engine::decode(
3241        &base64::engine::general_purpose::STANDARD,
3242        body.content.as_bytes(),
3243    ) else {
3244        return (
3245            StatusCode::BAD_REQUEST,
3246            Json(serde_json::json!({"error": "Invalid base64 content"})),
3247        )
3248            .into_response();
3249    };
3250
3251    if data.len() > MAX_FILE_BYTES {
3252        return (
3253            StatusCode::PAYLOAD_TOO_LARGE,
3254            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3255        )
3256            .into_response();
3257    }
3258
3259    // Sanitise: strip any directory component from the filename.
3260    let filename = std::path::Path::new(&body.filename)
3261        .file_name()
3262        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3263
3264    let upload_id = uuid::Uuid::new_v4();
3265    let staging = std::env::temp_dir()
3266        .join("oxide-sloc-uploads")
3267        .join(upload_id.to_string());
3268
3269    if tokio::fs::create_dir_all(&staging).await.is_err() {
3270        return (
3271            StatusCode::INTERNAL_SERVER_ERROR,
3272            Json(serde_json::json!({"error": "Failed to create staging directory"})),
3273        )
3274            .into_response();
3275    }
3276
3277    let dest = staging.join(&filename);
3278    if tokio::fs::write(&dest, &data).await.is_err() {
3279        let _ = tokio::fs::remove_dir_all(&staging).await;
3280        return (
3281            StatusCode::INTERNAL_SERVER_ERROR,
3282            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3283        )
3284            .into_response();
3285    }
3286
3287    Json(serde_json::json!({
3288        "tmp_path": dest.to_string_lossy(),
3289        "upload_id": upload_id.to_string()
3290    }))
3291    .into_response()
3292}
3293
3294/// POST /api/upload-tarball
3295///
3296/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
3297/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
3298/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
3299/// project root. The two size fields power the "Original / Compressed project size" display in the
3300/// web UI.
3301///
3302/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
3303/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
3304/// cap during decompression. The browser-side JS creates the archive one file at a time using
3305/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
3306/// project size.
3307/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
3308/// decompressed. Wraps any `std::io::Read` source.
3309struct SizeLimitReader<R> {
3310    inner: R,
3311    remaining: u64,
3312}
3313impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3314    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3315        if self.remaining == 0 {
3316            return Err(std::io::Error::other("decompressed size limit exceeded"));
3317        }
3318        let n = self.inner.read(buf)?;
3319        self.remaining = self.remaining.saturating_sub(n as u64);
3320        Ok(n)
3321    }
3322}
3323
3324async fn upload_tarball_handler(
3325    State(state): State<AppState>,
3326    request: axum::extract::Request,
3327) -> Response {
3328    if !state.server_mode {
3329        return StatusCode::NOT_FOUND.into_response();
3330    }
3331
3332    let upload_id = uuid::Uuid::new_v4().to_string();
3333    let upload_base = upload_base_dir();
3334    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3335    let staging = upload_staging_path(&upload_id);
3336    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3337
3338    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3339        tracing::error!(
3340            event = "upload_io_error",
3341            "failed to create upload base dir: {e}"
3342        );
3343        return (
3344            StatusCode::INTERNAL_SERVER_ERROR,
3345            Json(serde_json::json!({"error": "Upload initialization failed"})),
3346        )
3347            .into_response();
3348    }
3349
3350    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
3351    let compressed_bytes =
3352        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3353            Ok(n) => n,
3354            Err(resp) => return resp,
3355        };
3356
3357    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
3358    if let Err(resp) =
3359        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3360    {
3361        return resp;
3362    }
3363
3364    // ── 3. Find the project root inside the staging dir ───────────────────────
3365    // If the tar contained a single top-level directory (the common case when the
3366    // browser uses `webkitRelativePath`), return that as the scan root so the path
3367    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
3368    let scan_root = find_single_top_dir(&staging)
3369        .await
3370        .unwrap_or_else(|| staging.clone());
3371
3372    // Compute original (uncompressed) size of the extracted tree.
3373    let original_bytes = tokio::task::spawn_blocking({
3374        let p = scan_root.clone();
3375        move || dir_size_bytes(&p)
3376    })
3377    .await
3378    .unwrap_or(0);
3379
3380    Json(serde_json::json!({
3381        "tmp_path": scan_root.to_string_lossy(),
3382        "upload_id": upload_id,
3383        "compressed_bytes": compressed_bytes,
3384        "original_bytes": original_bytes,
3385    }))
3386    .into_response()
3387}
3388
3389#[derive(Deserialize)]
3390struct LocateReportForm {
3391    file_path: String,
3392    #[serde(default)]
3393    redirect_url: Option<String>,
3394    #[serde(default)]
3395    expected_run_id: Option<String>,
3396}
3397
3398/// Render a view-reports error page and return it as a `Response`.
3399fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3400    let html = ErrorTemplate {
3401        message: message.into(),
3402        last_report_url: Some("/view-reports".to_string()),
3403        last_report_label: Some("View Reports".to_string()),
3404        run_id: None,
3405        error_code: None,
3406        csp_nonce: csp_nonce.to_owned(),
3407        version: env!("CARGO_PKG_VERSION"),
3408    }
3409    .render()
3410    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3411    Html(html).into_response()
3412}
3413
3414/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
3415fn registry_entry_from_run(
3416    run: &AnalysisRun,
3417    json_path: PathBuf,
3418    html_path: PathBuf,
3419) -> RegistryEntry {
3420    let project_label = run.input_roots.first().map_or_else(
3421        || "Unknown Project".to_string(),
3422        |r| sanitize_project_label(r),
3423    );
3424    RegistryEntry {
3425        run_id: run.tool.run_id.clone(),
3426        timestamp_utc: run.tool.timestamp_utc,
3427        project_label,
3428        input_roots: run.input_roots.clone(),
3429        json_path: Some(json_path),
3430        html_path: Some(html_path),
3431        pdf_path: None,
3432        summary: ScanSummarySnapshot::from(&run.summary_totals),
3433        csv_path: None,
3434        xlsx_path: None,
3435        git_branch: None,
3436        git_commit: None,
3437        git_commit_long: None,
3438        git_author: None,
3439        git_tags: None,
3440        git_nearest_tag: None,
3441        git_commit_date: None,
3442    }
3443}
3444
3445/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
3446/// immediately without requiring a server restart.
3447pub(crate) async fn register_artifacts_in_registry(
3448    state: &AppState,
3449    label: &str,
3450    run: &AnalysisRun,
3451    artifacts: &RunArtifacts,
3452) {
3453    let Some(json_path) = artifacts.json_path.clone() else {
3454        return;
3455    };
3456    let Some(html_path) = artifacts.html_path.clone() else {
3457        return;
3458    };
3459    let mut entry = registry_entry_from_run(run, json_path, html_path);
3460    entry.project_label = label.to_owned();
3461    let mut reg = state.registry.lock().await;
3462    reg.add_entry(entry);
3463    let _ = reg.save(&state.registry_path);
3464}
3465
3466fn is_html_report_file(p: &Path) -> bool {
3467    p.is_file()
3468        && p.extension()
3469            .and_then(|x| x.to_str())
3470            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3471        && p.file_name()
3472            .and_then(|n| n.to_str())
3473            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3474}
3475
3476fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3477    fs::read_dir(dir)
3478        .ok()?
3479        .flatten()
3480        .map(|e| e.path())
3481        .find(|p| is_html_report_file(p))
3482}
3483
3484fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3485    if let Some(f) = find_html_report_in_dir(dir) {
3486        return Some(f);
3487    }
3488    if let Ok(rd) = fs::read_dir(dir) {
3489        for entry in rd.flatten() {
3490            let sub = entry.path();
3491            if sub.is_dir() {
3492                if let Some(f) = find_html_report_in_dir(&sub) {
3493                    return Some(f);
3494                }
3495            }
3496        }
3497    }
3498    None
3499}
3500
3501/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
3502/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
3503///
3504/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
3505#[allow(clippy::result_large_err)]
3506fn validate_locate_request(
3507    state: &AppState,
3508    file_path: &str,
3509    csp_nonce: &str,
3510) -> Result<(PathBuf, PathBuf), Response> {
3511    let raw = PathBuf::from(file_path);
3512
3513    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
3514    let html_path = if raw.is_dir() {
3515        let found = find_html_report_in_tree(&raw);
3516        match found {
3517            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3518            None => {
3519                return Err(locate_report_error(
3520                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
3521                     the folder that contains your scan output (result_*.html or report_*.html).",
3522                    csp_nonce,
3523                ));
3524            }
3525        }
3526    } else {
3527        let file_ext = raw
3528            .extension()
3529            .and_then(|e| e.to_str())
3530            .unwrap_or("")
3531            .to_ascii_lowercase();
3532        if file_ext != "html" {
3533            return Err(locate_report_error(
3534                "Please select the scan output folder, or an .html report file directly.",
3535                csp_nonce,
3536            ));
3537        }
3538        match fs::canonicalize(&raw) {
3539            Ok(p) => strip_unc_prefix(p),
3540            Err(_) => {
3541                return Err(locate_report_error(
3542                    "Report file not found or path is invalid.",
3543                    csp_nonce,
3544                ));
3545            }
3546        }
3547    };
3548
3549    if state.server_mode {
3550        let output_root = resolve_output_root(None);
3551        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3552        if !html_path.starts_with(&canonical_root) {
3553            return Err(locate_report_error(
3554                "Report file must be within the configured output directory.",
3555                csp_nonce,
3556            ));
3557        }
3558    }
3559    let parent = match html_path.parent() {
3560        Some(p) => p.to_path_buf(),
3561        None => {
3562            return Err(locate_report_error(
3563                "Report file has no parent directory.",
3564                csp_nonce,
3565            ));
3566        }
3567    };
3568    Ok((html_path, parent))
3569}
3570
3571/// JSON-or-HTML error for `locate_report_handler` error paths.
3572fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3573    if want_json {
3574        (
3575            StatusCode::UNPROCESSABLE_ENTITY,
3576            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3577        )
3578            .into_response()
3579    } else {
3580        locate_report_error(msg, csp_nonce)
3581    }
3582}
3583
3584/// JSON-or-redirect success for locate/relocate handler success paths.
3585fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3586    if want_json {
3587        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3588    } else {
3589        axum::response::Redirect::to(redirect).into_response()
3590    }
3591}
3592
3593/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
3594/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
3595fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3596    for jpath in candidates {
3597        if let Ok(run) = read_json(jpath) {
3598            if expected.is_empty() || run.tool.run_id == expected {
3599                return Some((jpath.clone(), run.tool.run_id));
3600            }
3601        }
3602    }
3603    None
3604}
3605
3606fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3607    html_path
3608        .parent()
3609        .and_then(|p| p.parent())
3610        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3611}
3612
3613fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3614    let mut hits = collect_result_json_candidates(scan_root);
3615    if hits.is_empty() {
3616        hits = collect_result_json_candidates(parent);
3617    }
3618    hits.sort();
3619    hits
3620}
3621
3622#[allow(clippy::too_many_lines)]
3623async fn locate_report_handler(
3624    State(state): State<AppState>,
3625    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3626    headers: axum::http::HeaderMap,
3627    Form(form): Form<LocateReportForm>,
3628) -> impl IntoResponse {
3629    let want_json = headers
3630        .get(axum::http::header::ACCEPT)
3631        .and_then(|v| v.to_str().ok())
3632        .is_some_and(|v| v.contains("application/json"));
3633
3634    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3635        Ok(v) => v,
3636        Err(resp) => {
3637            if want_json {
3638                return locate_handler_err(
3639                    true,
3640                    "No HTML report file found in the selected folder. \
3641                     Make sure you selected the folder that contains your \
3642                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
3643                        .to_string(),
3644                    &csp_nonce,
3645                );
3646            }
3647            return resp;
3648        }
3649    };
3650
3651    // Search for result_*.json in the HTML's parent and also its grandparent (handles
3652    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
3653    let scan_root_owned = resolve_scan_root(&html_path, &parent);
3654    let scan_root: &Path = &scan_root_owned;
3655    let json_candidates = gather_json_candidates(scan_root, &parent);
3656
3657    // If the expected_run_id was provided, find a JSON that matches it exactly.
3658    let expected_run_id = form
3659        .expected_run_id
3660        .as_deref()
3661        .unwrap_or("")
3662        .trim()
3663        .to_string();
3664
3665    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3666
3667    // If we have candidates but none matched the expected run_id, surface a clear error.
3668    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3669        let actual = json_candidates
3670            .iter()
3671            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3672            .unwrap_or_else(|| "unknown".to_string());
3673        return locate_handler_err(
3674            want_json,
3675            format!(
3676                "This folder contains a different scan.\n\n\
3677                 Expected run ID : {expected_run_id}\n\
3678                 Found run ID    : {actual}\n\n\
3679                 Please select the folder that contains the correct scan output."
3680            ),
3681            &csp_nonce,
3682        );
3683    }
3684
3685    let safe_redirect = form
3686        .redirect_url
3687        .as_deref()
3688        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3689        .unwrap_or("/view-reports?linked=1")
3690        .to_string();
3691
3692    let mut reg = state.registry.lock().await;
3693
3694    if let Some((json_path, run_id)) = matched_json {
3695        // Match by run_id in the registry (works even after files are moved).
3696        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3697            entry.html_path = Some(html_path);
3698            entry.json_path = Some(json_path);
3699            let _ = reg.save(&state.registry_path);
3700            drop(reg);
3701            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
3702            state.artifacts.lock().await.remove(&run_id);
3703            return redirect_or_json_ok(want_json, &safe_redirect);
3704        }
3705        // No existing entry — build one from the JSON.
3706        match read_json(&json_path) {
3707            Ok(run) => {
3708                let entry = registry_entry_from_run(&run, json_path, html_path);
3709                reg.add_entry(entry);
3710                let _ = reg.save(&state.registry_path);
3711                drop(reg);
3712                state.artifacts.lock().await.remove(&run_id);
3713                return redirect_or_json_ok(want_json, &safe_redirect);
3714            }
3715            Err(e) => {
3716                drop(reg);
3717                return locate_handler_err(
3718                    want_json,
3719                    format!(
3720                        "Found the scan folder but could not parse the result JSON.\n\n\
3721                         The file may have been saved by an older version of OxideSLOC. \
3722                         Re-running the analysis will create a fresh, compatible record.\n\n\
3723                         Error: {e}"
3724                    ),
3725                    &csp_nonce,
3726                );
3727            }
3728        }
3729    }
3730
3731    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
3732    if let Some(entry) = reg
3733        .entries
3734        .iter_mut()
3735        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3736    {
3737        entry.html_path = Some(html_path.clone());
3738        let _ = reg.save(&state.registry_path);
3739        drop(reg);
3740        state.artifacts.lock().await.remove(&expected_run_id);
3741        return redirect_or_json_ok(want_json, &safe_redirect);
3742    }
3743
3744    drop(reg);
3745    let hint = if state.server_mode {
3746        String::new()
3747    } else {
3748        format!(
3749            "\n\nSearched folder : {}\nHTML found      : {}",
3750            scan_root.display(),
3751            html_path.display()
3752        )
3753    };
3754    locate_handler_err(
3755        want_json,
3756        format!(
3757            "Could not link this report.\n\n\
3758             No result_*.json was found in the selected folder. \
3759             Make sure you selected the top-level scan output folder \
3760             (the one that contains html/, json/, pdf/ subfolders).{hint}"
3761        ),
3762        &csp_nonce,
3763    )
3764}
3765
3766/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
3767fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3768    fs::read_dir(dir)
3769        .ok()?
3770        .flatten()
3771        .map(|e| e.path())
3772        .find(|p| {
3773            p.is_file()
3774                && p.file_stem()
3775                    .and_then(|n| n.to_str())
3776                    .is_some_and(|n| n.starts_with("result"))
3777                && p.extension()
3778                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3779        })
3780}
3781
3782#[derive(Deserialize)]
3783struct LocateReportsDirForm {
3784    folder_path: String,
3785}
3786
3787#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
3788async fn locate_reports_dir_handler(
3789    State(state): State<AppState>,
3790    Form(form): Form<LocateReportsDirForm>,
3791) -> impl IntoResponse {
3792    if state.server_mode {
3793        return StatusCode::NOT_FOUND.into_response();
3794    }
3795    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3796        Ok(p) => strip_unc_prefix(p),
3797        Err(_) => {
3798            return axum::response::Redirect::to(
3799                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3800            )
3801            .into_response();
3802        }
3803    };
3804    if !folder.is_dir() {
3805        return axum::response::Redirect::to(
3806            "/view-reports?error=Selected+path+is+not+a+directory.",
3807        )
3808        .into_response();
3809    }
3810
3811    let candidates = collect_result_json_candidates(&folder);
3812
3813    if candidates.is_empty() {
3814        return axum::response::Redirect::to(
3815            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3816        )
3817        .into_response();
3818    }
3819
3820    let mut linked_count: usize = 0;
3821    let mut reg = state.registry.lock().await;
3822    for json_path in candidates {
3823        let Some(parent) = json_path.parent().map(PathBuf::from) else {
3824            continue;
3825        };
3826        if is_dir_already_registered(&reg, &parent) {
3827            continue;
3828        }
3829        let Some(entry) = build_registry_entry_from_json(json_path) else {
3830            continue;
3831        };
3832        reg.add_entry(entry);
3833        linked_count += 1;
3834    }
3835    let _ = reg.save(&state.registry_path);
3836    drop(reg);
3837
3838    if linked_count == 0 {
3839        return axum::response::Redirect::to(
3840            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3841        )
3842        .into_response();
3843    }
3844    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3845}
3846
3847#[derive(Deserialize)]
3848struct RelocateScanForm {
3849    run_id: String,
3850    folder_path: String,
3851    redirect_url: String,
3852}
3853
3854/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
3855/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
3856fn relocate_folder_err(
3857    want_json: bool,
3858    status: StatusCode,
3859    msg: &str,
3860    run_id: &str,
3861    folder_hint: &str,
3862    redirect_url: &str,
3863    csp_nonce: &str,
3864) -> Response {
3865    if want_json {
3866        (
3867            status,
3868            axum::Json(serde_json::json!({"ok": false, "message": msg})),
3869        )
3870            .into_response()
3871    } else {
3872        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3873    }
3874}
3875
3876#[allow(clippy::too_many_lines)]
3877async fn relocate_scan_handler(
3878    State(state): State<AppState>,
3879    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3880    headers: axum::http::HeaderMap,
3881    Form(form): Form<RelocateScanForm>,
3882) -> impl IntoResponse {
3883    let want_json = headers
3884        .get(axum::http::header::ACCEPT)
3885        .and_then(|v| v.to_str().ok())
3886        .is_some_and(|v| v.contains("application/json"));
3887    if state.server_mode {
3888        return StatusCode::NOT_FOUND.into_response();
3889    }
3890
3891    let run_id = form.run_id.trim().to_string();
3892    let redirect_url = form.redirect_url.trim().to_string();
3893
3894    let run_exists = {
3895        let reg = state.registry.lock().await;
3896        reg.find_by_run_id(&run_id).is_some()
3897    };
3898    if !run_exists {
3899        if want_json {
3900            return (
3901                StatusCode::NOT_FOUND,
3902                axum::Json(serde_json::json!({
3903                    "ok": false,
3904                    "message": format!("Run ID '{run_id}' not found in registry.")
3905                })),
3906            )
3907                .into_response();
3908        }
3909        let html = ErrorTemplate {
3910            message: format!("Run ID '{run_id}' not found in registry."),
3911            last_report_url: Some("/compare-scans".to_string()),
3912            last_report_label: Some("Compare Scans".to_string()),
3913            run_id: Some(run_id.clone()),
3914            error_code: Some(404),
3915            csp_nonce: csp_nonce.clone(),
3916            version: env!("CARGO_PKG_VERSION"),
3917        }
3918        .render()
3919        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3920        return Html(html).into_response();
3921    }
3922
3923    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3924        Ok(p) => strip_unc_prefix(p),
3925        Err(_) => {
3926            return relocate_folder_err(
3927                want_json,
3928                StatusCode::UNPROCESSABLE_ENTITY,
3929                "Folder not found or path is invalid.",
3930                &run_id,
3931                form.folder_path.trim(),
3932                &redirect_url,
3933                &csp_nonce,
3934            );
3935        }
3936    };
3937    if !folder.is_dir() {
3938        return relocate_folder_err(
3939            want_json,
3940            StatusCode::UNPROCESSABLE_ENTITY,
3941            "Selected path is not a directory.",
3942            &run_id,
3943            &folder.display().to_string(),
3944            &redirect_url,
3945            &csp_nonce,
3946        );
3947    }
3948
3949    let json_candidates = find_result_files_by_ext(&folder, "json");
3950    if json_candidates.is_empty() {
3951        let msg = format!(
3952            "No result JSON files found in the selected folder.\nSearched: {}",
3953            folder.display()
3954        );
3955        return relocate_folder_err(
3956            want_json,
3957            StatusCode::UNPROCESSABLE_ENTITY,
3958            &msg,
3959            &run_id,
3960            &folder.display().to_string(),
3961            &redirect_url,
3962            &csp_nonce,
3963        );
3964    }
3965
3966    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3967        let msg = format!(
3968            "No matching scan found in the selected folder.\n\
3969             The JSON files present do not contain run ID: {run_id}\n\
3970             Searched: {}",
3971            folder.display()
3972        );
3973        return relocate_folder_err(
3974            want_json,
3975            StatusCode::UNPROCESSABLE_ENTITY,
3976            &msg,
3977            &run_id,
3978            &folder.display().to_string(),
3979            &redirect_url,
3980            &csp_nonce,
3981        );
3982    };
3983
3984    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3985    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3986    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3987
3988    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3989        redirect_url
3990    } else {
3991        "/compare-scans".to_string()
3992    };
3993    redirect_or_json_ok(want_json, &safe_redirect)
3994}
3995
3996fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3997    let mut out = Vec::new();
3998    collect_scan_files_by_ext(folder, ext, &mut out);
3999    if let Ok(rd) = fs::read_dir(folder) {
4000        for entry in rd.flatten() {
4001            let sub = entry.path();
4002            if sub.is_dir() {
4003                collect_scan_files_by_ext(&sub, ext, &mut out);
4004            }
4005        }
4006    }
4007    out
4008}
4009
4010fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4011    let Ok(rd) = fs::read_dir(dir) else { return };
4012    for entry in rd.flatten() {
4013        let p = entry.path();
4014        if p.is_file()
4015            && p.file_stem()
4016                .and_then(|n| n.to_str())
4017                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4018            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4019        {
4020            out.push(p);
4021        }
4022    }
4023}
4024
4025fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4026    candidates
4027        .iter()
4028        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4029        .cloned()
4030}
4031
4032/// Return the best folder hint for the relocate page.
4033/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
4034/// point at the parent — the actual top-level output directory — so the user
4035/// selects the root folder rather than the subfolder.
4036fn output_folder_hint(json_path: &std::path::Path) -> String {
4037    let Some(direct_parent) = json_path.parent() else {
4038        return String::new();
4039    };
4040    let parent_name = direct_parent
4041        .file_name()
4042        .and_then(|n| n.to_str())
4043        .unwrap_or("");
4044    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4045        direct_parent.parent().map_or_else(
4046            || direct_parent.display().to_string(),
4047            |p| p.display().to_string(),
4048        )
4049    } else {
4050        direct_parent.display().to_string()
4051    }
4052}
4053
4054async fn update_run_file_paths(
4055    state: &AppState,
4056    run_id: &str,
4057    json_path: PathBuf,
4058    html_path: Option<PathBuf>,
4059    pdf_path: Option<PathBuf>,
4060) {
4061    {
4062        let mut reg = state.registry.lock().await;
4063        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4064            entry.json_path = Some(json_path.clone());
4065            if let Some(ref hp) = html_path {
4066                entry.html_path = Some(hp.clone());
4067            }
4068            if let Some(ref pp) = pdf_path {
4069                entry.pdf_path = Some(pp.clone());
4070            }
4071        }
4072        let _ = reg.save(&state.registry_path);
4073    }
4074    // Also patch the in-memory artifacts map so the result page picks up the
4075    // new paths without requiring a server restart.
4076    {
4077        let mut map = state.artifacts.lock().await;
4078        if let Some(arts) = map.get_mut(run_id) {
4079            arts.json_path = Some(json_path);
4080            if let Some(hp) = html_path {
4081                arts.html_path = Some(hp);
4082            }
4083            if let Some(pp) = pdf_path {
4084                arts.pdf_path = Some(pp);
4085            }
4086        }
4087    }
4088}
4089
4090fn missing_scan_relocate_response(
4091    message: &str,
4092    run_id: &str,
4093    folder_hint: &str,
4094    redirect_url: &str,
4095    server_mode: bool,
4096    csp_nonce: &str,
4097) -> axum::response::Response {
4098    let html = RelocateScanTemplate {
4099        message: message.to_string(),
4100        run_id: run_id.to_string(),
4101        folder_hint: folder_hint.to_string(),
4102        redirect_url: redirect_url.to_string(),
4103        server_mode,
4104        csp_nonce: csp_nonce.to_owned(),
4105        version: env!("CARGO_PKG_VERSION"),
4106    }
4107    .render()
4108    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4109    (StatusCode::NOT_FOUND, Html(html)).into_response()
4110}
4111
4112// ── Watched-directory helpers ─────────────────────────────────────────────────
4113
4114/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
4115fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4116    fs::read_dir(dir)
4117        .ok()?
4118        .flatten()
4119        .map(|e| e.path())
4120        .find(|p| {
4121            p.is_file()
4122                && p.extension()
4123                    .and_then(|e| e.to_str())
4124                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4125        })
4126}
4127
4128/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
4129/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
4130/// (`<scan_dir>/json/result*.json`).
4131fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4132    let mut out = Vec::new();
4133    if let Some(j) = find_result_json_in_dir(sub) {
4134        out.push(j);
4135    }
4136    let json_sub = sub.join("json");
4137    if json_sub.is_dir() {
4138        if let Some(j) = find_result_json_in_dir(&json_sub) {
4139            out.push(j);
4140        }
4141    }
4142    out
4143}
4144
4145fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4146    let mut candidates = Vec::new();
4147    if let Some(j) = find_result_json_in_dir(folder) {
4148        candidates.push(j);
4149    }
4150    let Ok(dir_entries) = fs::read_dir(folder) else {
4151        return candidates;
4152    };
4153    for entry in dir_entries.flatten() {
4154        let sub = entry.path();
4155        if sub.is_dir() {
4156            candidates.extend(subdir_result_json_candidates(&sub));
4157        }
4158    }
4159    candidates
4160}
4161
4162fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4163    reg.entries.iter().any(|e| {
4164        let dir_match = e
4165            .json_path
4166            .as_ref()
4167            .and_then(|p| p.parent())
4168            .is_some_and(|p| p == parent)
4169            || e.html_path
4170                .as_ref()
4171                .and_then(|p| p.parent())
4172                .is_some_and(|p| p == parent);
4173        dir_match
4174            && (e.json_path.as_ref().is_some_and(|p| p.exists())
4175                || e.html_path.as_ref().is_some_and(|p| p.exists()))
4176    })
4177}
4178
4179fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4180    let json_dir = json_path.parent()?.to_path_buf();
4181    // If the JSON lives inside a directory named "json", the scan root is its parent
4182    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
4183    let (html_path, pdf_path, csv_path, xlsx_path) =
4184        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4185            let scan_root = json_dir.parent()?;
4186            let html = find_html_report_in_dir(&scan_root.join("html"))
4187                .or_else(|| find_html_report_in_dir(scan_root));
4188            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4189            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4190            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4191            (html, pdf, csv, xlsx)
4192        } else {
4193            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4194                rd.flatten()
4195                    .map(|e| e.path())
4196                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4197            });
4198            (html, None, None, None)
4199        };
4200    let run = read_json(&json_path).ok()?;
4201    let project_label = run.input_roots.first().map_or_else(
4202        || "Unknown Project".to_string(),
4203        |r| sanitize_project_label(r),
4204    );
4205    Some(RegistryEntry {
4206        run_id: run.tool.run_id.clone(),
4207        timestamp_utc: run.tool.timestamp_utc,
4208        project_label,
4209        input_roots: run.input_roots.clone(),
4210        json_path: Some(json_path),
4211        html_path,
4212        pdf_path,
4213        csv_path,
4214        xlsx_path,
4215        summary: ScanSummarySnapshot::from(&run.summary_totals),
4216        git_branch: run.git_branch.clone(),
4217        git_commit: run.git_commit_short.clone(),
4218        git_commit_long: run.git_commit_long.clone(),
4219        git_author: run.git_commit_author.clone(),
4220        git_tags: run.git_tags.clone(),
4221        git_nearest_tag: run.git_nearest_tag.clone(),
4222        git_commit_date: run.git_commit_date,
4223    })
4224}
4225
4226/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
4227/// Returns the number of newly linked entries.
4228fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4229    let mut linked = 0usize;
4230    for json_path in collect_result_json_candidates(folder) {
4231        let Some(parent) = json_path.parent().map(PathBuf::from) else {
4232            continue;
4233        };
4234        if is_dir_already_registered(reg, &parent) {
4235            continue;
4236        }
4237        let Some(entry) = build_registry_entry_from_json(json_path) else {
4238            continue;
4239        };
4240        reg.add_entry(entry);
4241        linked += 1;
4242    }
4243    linked
4244}
4245
4246/// Scan all watched directories (plus the default output root) into `reg`.
4247async fn auto_scan_watched_dirs(state: &AppState) {
4248    let dirs: Vec<PathBuf> = {
4249        let wd = state.watched_dirs.lock().await;
4250        wd.dirs.clone()
4251    };
4252    // Reconcile the registry to the watched-folder model: keep only entries under a
4253    // currently-watched folder or the app's own output directory. This drops leftovers from
4254    // folders that have since been un-watched (which would otherwise linger in the list).
4255    {
4256        let output_root = resolve_output_root(None);
4257        let mut roots: Vec<PathBuf> = dirs.clone();
4258        if let Ok(canon) = fs::canonicalize(&output_root) {
4259            roots.push(strip_unc_prefix(canon));
4260        }
4261        roots.push(output_root);
4262        let mut reg = state.registry.lock().await;
4263        if reg.retain_under_roots(&roots) > 0 {
4264            let _ = reg.save(&state.registry_path);
4265        }
4266    }
4267    if dirs.is_empty() {
4268        return;
4269    }
4270    let mut reg = state.registry.lock().await;
4271    let mut total = 0usize;
4272    for dir in &dirs {
4273        if dir.is_dir() {
4274            total += scan_folder_into_registry(dir, &mut reg);
4275        }
4276    }
4277    if total > 0 {
4278        let _ = reg.save(&state.registry_path);
4279    }
4280}
4281
4282// ── Watched-dir route forms ───────────────────────────────────────────────────
4283
4284#[derive(Deserialize)]
4285struct WatchedDirForm {
4286    folder_path: String,
4287    #[serde(default = "default_redirect")]
4288    redirect_to: String,
4289}
4290
4291fn default_redirect() -> String {
4292    "/view-reports".to_string()
4293}
4294
4295#[derive(Deserialize)]
4296struct WatchedDirRefreshForm {
4297    #[serde(default = "default_redirect")]
4298    redirect_to: String,
4299}
4300
4301// ── Watched-dir helpers ───────────────────────────────────────────────────────
4302
4303/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
4304fn safe_redirect(dest: &str) -> &str {
4305    if dest.starts_with('/') {
4306        dest
4307    } else {
4308        "/"
4309    }
4310}
4311
4312// ── Watched-dir handlers ──────────────────────────────────────────────────────
4313
4314async fn add_watched_dir_handler(
4315    State(state): State<AppState>,
4316    Form(form): Form<WatchedDirForm>,
4317) -> impl IntoResponse {
4318    if state.server_mode {
4319        return StatusCode::NOT_FOUND.into_response();
4320    }
4321    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4322        strip_unc_prefix(p)
4323    } else {
4324        let dest = format!(
4325            "{}?error=Folder+not+found+or+path+is+invalid.",
4326            safe_redirect(&form.redirect_to)
4327        );
4328        return axum::response::Redirect::to(&dest).into_response();
4329    };
4330    if !folder.is_dir() {
4331        let dest = format!(
4332            "{}?error=Selected+path+is+not+a+directory.",
4333            safe_redirect(&form.redirect_to)
4334        );
4335        return axum::response::Redirect::to(&dest).into_response();
4336    }
4337
4338    // Persist the watched directory.
4339    {
4340        let mut wd = state.watched_dirs.lock().await;
4341        wd.add(folder.clone());
4342        let _ = wd.save(&state.watched_dirs_path);
4343    }
4344
4345    // Immediately scan the folder and add any new reports.
4346    let linked = {
4347        let mut reg = state.registry.lock().await;
4348        let n = scan_folder_into_registry(&folder, &mut reg);
4349        if n > 0 {
4350            let _ = reg.save(&state.registry_path);
4351        }
4352        n
4353    };
4354
4355    let dest = if linked > 0 {
4356        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4357    } else {
4358        format!(
4359            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4360            safe_redirect(&form.redirect_to)
4361        )
4362    };
4363    axum::response::Redirect::to(&dest).into_response()
4364}
4365
4366async fn remove_watched_dir_handler(
4367    State(state): State<AppState>,
4368    Form(form): Form<WatchedDirForm>,
4369) -> impl IntoResponse {
4370    if state.server_mode {
4371        return StatusCode::NOT_FOUND.into_response();
4372    }
4373    let folder = PathBuf::from(&form.folder_path);
4374    {
4375        let mut wd = state.watched_dirs.lock().await;
4376        wd.remove(&folder);
4377        let _ = wd.save(&state.watched_dirs_path);
4378    }
4379    // Drop any reports that were linked in from this folder so the list reflects the removal.
4380    {
4381        let mut reg = state.registry.lock().await;
4382        if reg.remove_entries_under(&folder) > 0 {
4383            let _ = reg.save(&state.registry_path);
4384        }
4385    }
4386    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4387}
4388
4389async fn refresh_watched_dirs_handler(
4390    State(state): State<AppState>,
4391    Form(form): Form<WatchedDirRefreshForm>,
4392) -> impl IntoResponse {
4393    if state.server_mode {
4394        return StatusCode::NOT_FOUND.into_response();
4395    }
4396    let dirs: Vec<PathBuf> = {
4397        let wd = state.watched_dirs.lock().await;
4398        wd.dirs.clone()
4399    };
4400    let mut total = 0usize;
4401    {
4402        let mut reg = state.registry.lock().await;
4403        reg.prune_stale();
4404        for dir in &dirs {
4405            if dir.is_dir() {
4406                total += scan_folder_into_registry(dir, &mut reg);
4407            }
4408        }
4409        let _ = reg.save(&state.registry_path);
4410    }
4411    let dest = if total > 0 {
4412        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4413    } else {
4414        safe_redirect(&form.redirect_to).to_owned()
4415    };
4416    axum::response::Redirect::to(&dest).into_response()
4417}
4418
4419#[derive(Debug, Deserialize)]
4420struct OpenPathQuery {
4421    path: Option<String>,
4422}
4423
4424fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4425    let mut ancestor = std::path::Path::new(raw);
4426    loop {
4427        match ancestor.parent() {
4428            Some(p) => {
4429                ancestor = p;
4430                if ancestor.is_dir() {
4431                    break;
4432                }
4433            }
4434            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4435        }
4436    }
4437    Ok(ancestor.to_path_buf())
4438}
4439
4440async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4441    match tokio::fs::canonicalize(raw).await {
4442        Ok(canonical) if canonical.is_file() => canonical
4443            .parent()
4444            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4445                Ok(p.to_path_buf())
4446            }),
4447        Ok(canonical) if canonical.is_dir() => Ok(canonical),
4448        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4449        Err(_) => find_existing_ancestor(raw),
4450    }
4451}
4452
4453async fn open_path_handler(
4454    State(state): State<AppState>,
4455    Query(query): Query<OpenPathQuery>,
4456) -> impl IntoResponse {
4457    if state.server_mode {
4458        return Json(serde_json::json!({
4459            "server_mode_disabled": true,
4460            "message": "Opening a path in the file manager is only available in local desktop mode."
4461        }))
4462        .into_response();
4463    }
4464    // Skip the OS file-manager call in headless / CI environments.
4465    if std::env::var("SLOC_HEADLESS").is_ok() {
4466        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4467    }
4468    let raw = match query.path.as_deref() {
4469        Some(p) if !p.is_empty() => p,
4470        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4471    };
4472
4473    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
4474    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
4475    // so the file explorer still opens somewhere useful.
4476    let target = match resolve_open_target(raw).await {
4477        Ok(p) => p,
4478        Err((code, msg)) => return (code, msg).into_response(),
4479    };
4480
4481    #[cfg(target_os = "windows")]
4482    win_dialog_focus::open_folder_foreground(target);
4483    #[cfg(target_os = "macos")]
4484    let _ = std::process::Command::new("open")
4485        .arg(&target)
4486        .stdout(Stdio::null())
4487        .stderr(Stdio::null())
4488        .spawn();
4489    #[cfg(target_os = "linux")]
4490    {
4491        let folder_name = target
4492            .file_name()
4493            .and_then(|n| n.to_str())
4494            .map(str::to_owned);
4495        let _ = std::process::Command::new("xdg-open")
4496            .arg(&target)
4497            .stdout(Stdio::null())
4498            .stderr(Stdio::null())
4499            .spawn();
4500        // Best-effort: raise the file manager window once it appears.
4501        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
4502        // installed; failures are silently discarded.
4503        if let Some(name) = folder_name {
4504            std::thread::spawn(move || {
4505                std::thread::sleep(std::time::Duration::from_millis(800));
4506                let _ = std::process::Command::new("wmctrl")
4507                    .args(["-a", &name])
4508                    .stdout(Stdio::null())
4509                    .stderr(Stdio::null())
4510                    .spawn();
4511            });
4512        }
4513    }
4514
4515    Json(serde_json::json!({"ok": true})).into_response()
4516}
4517
4518async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4519    let (content_type, bytes): (&'static str, &'static [u8]) =
4520        match (folder.as_str(), file.as_str()) {
4521            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4522            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4523            ("icons", "c.png") => ("image/png", IMG_ICON_C),
4524            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4525            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4526            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4527            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4528            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4529            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4530            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4531            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4532            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4533            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4534            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4535            ("icons", "r.png") => ("image/png", IMG_ICON_R),
4536            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4537            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4538            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4539            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4540            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4541            _ => return StatusCode::NOT_FOUND.into_response(),
4542        };
4543    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4544}
4545
4546/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
4547/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
4548/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
4549/// complexity low; the fail-closed semantics are unchanged.
4550fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4551    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
4552    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
4553    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
4554    // sample/upload locations are permitted; everything else is rejected.
4555    let Ok(canonical) = fs::canonicalize(resolved) else {
4556        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4557            return Err(Html(
4558                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4559            ));
4560        }
4561        return Ok(());
4562    };
4563    // Upload temp dirs and built-in sample/fixture paths are always safe.
4564    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4565        return Ok(());
4566    }
4567    let config = &state.base_config;
4568    if config.discovery.allowed_scan_roots.is_empty() {
4569        return Err(Html(
4570            r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4571        ));
4572    }
4573    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4574        fs::canonicalize(root)
4575            .ok()
4576            .is_some_and(|r| canonical.starts_with(&r))
4577    });
4578    if !allowed {
4579        return Err(Html(
4580            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4581        ));
4582    }
4583    Ok(())
4584}
4585
4586async fn preview_handler(
4587    State(state): State<AppState>,
4588    Query(query): Query<PreviewQuery>,
4589) -> impl IntoResponse {
4590    let raw_path = query
4591        .path
4592        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4593    let resolved = resolve_input_path(&raw_path);
4594
4595    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
4596    // binary whose working directory is not the project root), return a clear message
4597    // instead of an opaque OS error from build_preview_html.
4598    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4599        return Html(
4600            r#"<div class="preview-error">Sample directory not available on this server.
4601            Enter a path to a project directory or upload files using Browse.</div>"#
4602                .to_string(),
4603        );
4604    }
4605
4606    if state.server_mode {
4607        if let Err(resp) = authorize_preview_path(&state, &resolved) {
4608            return resp;
4609        }
4610    }
4611
4612    let include_patterns = split_patterns(query.include_globs.as_deref());
4613    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4614
4615    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4616        Ok(html) => Html(html),
4617        Err(err) => Html(format!(
4618            r#"<div class="preview-error">Preview failed: {}</div>"#,
4619            escape_html(&err.to_string())
4620        )),
4621    }
4622}
4623
4624#[derive(Debug, Deserialize, Default)]
4625struct SuggestCoverageQuery {
4626    path: Option<String>,
4627}
4628
4629#[derive(Serialize)]
4630struct SuggestCoverageResponse {
4631    found: Option<String>,
4632    tool: Option<&'static str>,
4633    hint: Option<&'static str>,
4634}
4635
4636async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4637    const CANDIDATES: &[&str] = &[
4638        // LCOV — cargo-llvm-cov, gcov, lcov
4639        "coverage/lcov.info",
4640        "lcov.info",
4641        "target/llvm-cov/lcov.info",
4642        "target/coverage/lcov.info",
4643        "target/debug/coverage/lcov.info",
4644        "coverage/coverage.lcov",
4645        "build/coverage/lcov.info",
4646        "reports/lcov.info",
4647        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
4648        "coverage.xml",
4649        "coverage/coverage.xml",
4650        "target/site/cobertura/coverage.xml",
4651        "build/reports/coverage/coverage.xml",
4652        // JaCoCo XML — Gradle, Maven JaCoCo plugin
4653        "target/site/jacoco/jacoco.xml",
4654        "build/reports/jacoco/test/jacocoTestReport.xml",
4655        "build/reports/jacoco/jacocoTestReport.xml",
4656        "build/jacoco/jacoco.xml",
4657        // coverage.py native JSON — `coverage json`
4658        "coverage.json",
4659        "coverage/coverage.json",
4660    ];
4661    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4662    let found = CANDIDATES
4663        .iter()
4664        .map(|rel| root.join(rel))
4665        .find(|p| p.is_file())
4666        .map(|p| display_path(&p));
4667
4668    let (tool, hint) = detect_coverage_tool(&root);
4669    Json(SuggestCoverageResponse { found, tool, hint })
4670}
4671
4672/// Inspect the project root for known build/package files and return the most likely coverage
4673/// tool name and the shell command needed to generate a coverage file.
4674fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4675    if root.join("Cargo.toml").is_file() {
4676        return (
4677            Some("cargo-llvm-cov"),
4678            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4679        );
4680    }
4681    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4682        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4683    }
4684    if root.join("pom.xml").is_file() {
4685        return (Some("jacoco"), Some("mvn test jacoco:report"));
4686    }
4687    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4688        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4689    }
4690    (None, None)
4691}
4692
4693/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
4694#[allow(clippy::result_large_err)]
4695fn validate_server_scan_path(
4696    config: &sloc_config::AppConfig,
4697    resolved_path: &Path,
4698    csp_nonce: &str,
4699) -> Result<(), Response> {
4700    if config.discovery.allowed_scan_roots.is_empty() {
4701        let template = ErrorTemplate {
4702            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4703                      Set allowed_scan_roots in the server config to permit scanning."
4704                .to_string(),
4705            last_report_url: None,
4706            last_report_label: None,
4707            run_id: None,
4708            error_code: Some(403),
4709            csp_nonce: csp_nonce.to_owned(),
4710            version: env!("CARGO_PKG_VERSION"),
4711        };
4712        return Err((
4713            StatusCode::FORBIDDEN,
4714            Html(
4715                template
4716                    .render()
4717                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4718            ),
4719        )
4720            .into_response());
4721    }
4722    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
4723    // location) we must NOT fall back to the raw, un-normalised path — a textual
4724    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
4725    // allowlist. A non-resolvable scan target is rejected outright.
4726    let Ok(canonical) = fs::canonicalize(resolved_path) else {
4727        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4728            "Scan path does not resolve to a real location");
4729        let template = ErrorTemplate {
4730            message: "The requested path could not be resolved to a real directory.".to_string(),
4731            last_report_url: None,
4732            last_report_label: None,
4733            run_id: None,
4734            error_code: Some(403),
4735            csp_nonce: csp_nonce.to_owned(),
4736            version: env!("CARGO_PKG_VERSION"),
4737        };
4738        return Err((
4739            StatusCode::FORBIDDEN,
4740            Html(
4741                template
4742                    .render()
4743                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4744            ),
4745        )
4746            .into_response());
4747    };
4748    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4749        fs::canonicalize(root)
4750            .ok()
4751            .is_some_and(|r| canonical.starts_with(&r))
4752    });
4753    if !allowed {
4754        tracing::warn!(event = "path_rejected", path = %canonical.display(),
4755            "Scan path not in allowed_scan_roots");
4756        let template = ErrorTemplate {
4757            message: "The requested path is not within an allowed scan directory.".to_string(),
4758            last_report_url: None,
4759            last_report_label: None,
4760            run_id: None,
4761            error_code: Some(403),
4762            csp_nonce: csp_nonce.to_owned(),
4763            version: env!("CARGO_PKG_VERSION"),
4764        };
4765        return Err((
4766            StatusCode::FORBIDDEN,
4767            Html(
4768                template
4769                    .render()
4770                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4771            ),
4772        )
4773            .into_response());
4774    }
4775    Ok(())
4776}
4777
4778/// Exclude the output directory from scanning so artifacts don't pollute counts.
4779fn apply_output_dir_exclusions(
4780    config: &mut sloc_config::AppConfig,
4781    project_path: &str,
4782    raw_output_dir: &str,
4783) {
4784    let project_root = resolve_input_path(project_path);
4785    let raw_out = raw_output_dir.trim();
4786    let resolved_out = if raw_out.is_empty() {
4787        project_root.join("sloc")
4788    } else if Path::new(raw_out).is_absolute() {
4789        PathBuf::from(raw_out)
4790    } else {
4791        workspace_root().join(raw_out)
4792    };
4793    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4794        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4795            let dir = first.to_string();
4796            if !config.discovery.excluded_directories.contains(&dir) {
4797                config.discovery.excluded_directories.push(dir);
4798            }
4799        }
4800    }
4801    if !config
4802        .discovery
4803        .excluded_directories
4804        .iter()
4805        .any(|d| d == "sloc")
4806    {
4807        config
4808            .discovery
4809            .excluded_directories
4810            .push("sloc".to_string());
4811    }
4812}
4813
4814/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
4815const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4816    ScanSummarySnapshot {
4817        files_analyzed: run.summary_totals.files_analyzed,
4818        files_skipped: run.summary_totals.files_skipped,
4819        total_physical_lines: run.summary_totals.total_physical_lines,
4820        code_lines: run.summary_totals.code_lines,
4821        comment_lines: run.summary_totals.comment_lines,
4822        blank_lines: run.summary_totals.blank_lines,
4823        functions: run.summary_totals.functions,
4824        classes: run.summary_totals.classes,
4825        variables: run.summary_totals.variables,
4826        imports: run.summary_totals.imports,
4827        test_count: run.summary_totals.test_count,
4828        coverage_lines_found: run.summary_totals.coverage_lines_found,
4829        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4830        coverage_functions_found: run.summary_totals.coverage_functions_found,
4831        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4832        coverage_branches_found: run.summary_totals.coverage_branches_found,
4833        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4834    }
4835}
4836
4837/// Build the `RegistryEntry` for the just-completed scan run.
4838pub(crate) fn build_run_registry_entry(
4839    run: &AnalysisRun,
4840    run_id: &str,
4841    project_label: &str,
4842    artifacts: &RunArtifacts,
4843) -> RegistryEntry {
4844    RegistryEntry {
4845        run_id: run_id.to_owned(),
4846        timestamp_utc: run.tool.timestamp_utc,
4847        project_label: project_label.to_owned(),
4848        input_roots: run.input_roots.clone(),
4849        json_path: artifacts.json_path.clone(),
4850        html_path: artifacts.html_path.clone(),
4851        pdf_path: artifacts.pdf_path.clone(),
4852        csv_path: artifacts.csv_path.clone(),
4853        xlsx_path: artifacts.xlsx_path.clone(),
4854        summary: summary_snapshot_from_run(run),
4855        git_branch: run.git_branch.clone(),
4856        git_commit: run.git_commit_short.clone(),
4857        git_commit_long: run.git_commit_long.clone(),
4858        git_author: run.git_commit_author.clone(),
4859        git_tags: run.git_tags.clone(),
4860        git_nearest_tag: run.git_nearest_tag.clone(),
4861        git_commit_date: run.git_commit_date.clone(),
4862    }
4863}
4864
4865/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
4866fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4867    if let Some(policy) = form.mixed_line_policy {
4868        config.analysis.mixed_line_policy = policy;
4869    }
4870    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4871    config.analysis.generated_file_detection =
4872        form.generated_file_detection.as_deref() != Some("disabled");
4873    config.analysis.minified_file_detection =
4874        form.minified_file_detection.as_deref() != Some("disabled");
4875    config.analysis.vendor_directory_detection =
4876        form.vendor_directory_detection.as_deref() != Some("disabled");
4877    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4878    if let Some(binary_behavior) = form.binary_file_behavior {
4879        config.analysis.binary_file_behavior = binary_behavior;
4880    }
4881    apply_report_opts(config, form);
4882    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4883    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4884    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4885    if let Some(policy) = form.continuation_line_policy {
4886        config.analysis.continuation_line_policy = policy;
4887    }
4888    if let Some(policy) = form.blank_in_block_comment_policy {
4889        config.analysis.blank_in_block_comment_policy = policy;
4890    }
4891    config.analysis.count_compiler_directives =
4892        form.count_compiler_directives.as_deref() != Some("disabled");
4893    apply_style_threshold(config, form);
4894    apply_coverage_path(config, form);
4895}
4896
4897fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4898    if let Some(report_title) = form.report_title.as_deref() {
4899        let trimmed = report_title.trim();
4900        if !trimmed.is_empty() {
4901            config.reporting.report_title = trimmed.to_string();
4902        }
4903    }
4904    if let Some(hf) = form.report_header_footer.as_deref() {
4905        let trimmed = hf.trim();
4906        config.reporting.report_header_footer = if trimmed.is_empty() {
4907            None
4908        } else {
4909            Some(trimmed.to_string())
4910        };
4911    }
4912}
4913
4914fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4915    apply_style_col_threshold(config, form);
4916    apply_style_analysis_enabled(config, form);
4917    apply_style_score_threshold(config, form);
4918    apply_style_lang_scope(config, form);
4919    apply_activity_window(config, form);
4920}
4921
4922fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4923    if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4924        if let Ok(t) = threshold_str.parse::<u16>() {
4925            if t == 80 || t == 100 || t == 120 {
4926                config.analysis.style_col_threshold = t;
4927            }
4928        }
4929    }
4930}
4931
4932fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4933    if let Some(v) = form.style_analysis_enabled.as_deref() {
4934        config.analysis.style_analysis_enabled = v != "disabled";
4935    }
4936}
4937
4938fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4939    if let Some(v) = form.style_score_threshold.as_deref() {
4940        if let Ok(t) = v.parse::<u8>() {
4941            config.analysis.style_score_threshold = t.min(100);
4942        }
4943    }
4944}
4945
4946fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4947    if let Some(v) = form.style_lang_scope.as_deref() {
4948        let scope = v.trim();
4949        if scope == "c_family" || scope == "all" {
4950            config.analysis.style_lang_scope = scope.to_string();
4951        }
4952    }
4953}
4954
4955fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4956    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
4957    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
4958    if let Some(w) = form.activity_window.as_deref() {
4959        let w = w.trim();
4960        if !w.is_empty() {
4961            if let Ok(days) = w.parse::<u32>() {
4962                config.analysis.activity_window_days = Some(days);
4963            }
4964        }
4965    }
4966}
4967
4968fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4969    if let Some(cov) = &form.coverage_file {
4970        let trimmed = cov.trim();
4971        if !trimmed.is_empty() {
4972            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4973        }
4974    }
4975}
4976
4977/// Fire-and-forget: generate the PDF in a background task if one is pending.
4978/// On failure, clears `pdf_path` in the artifacts map so the results page shows
4979/// an error instead of spinning indefinitely.
4980fn spawn_pdf_background(
4981    pending_pdf: PendingPdf,
4982    run_id: String,
4983    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4984) {
4985    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4986        tokio::spawn(async move {
4987            let result = tokio::task::spawn_blocking(move || {
4988                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4989                if cleanup_src {
4990                    let _ = fs::remove_file(&pdf_src);
4991                }
4992                r
4993            })
4994            .await;
4995            let failed = match result {
4996                Ok(Ok(())) => false,
4997                Ok(Err(err)) => {
4998                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4999                    true
5000                }
5001                Err(err) => {
5002                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5003                    true
5004                }
5005            };
5006            if failed {
5007                let mut map = artifacts.lock().await;
5008                if let Some(entry) = map.get_mut(&run_id) {
5009                    entry.pdf_path = None;
5010                }
5011            }
5012        });
5013    }
5014}
5015
5016/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
5017/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
5018/// result page can show an error on the next visit instead of spinning indefinitely.
5019fn spawn_native_pdf_background(
5020    json_path: PathBuf,
5021    pdf_dest: PathBuf,
5022    run_id: String,
5023    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5024) {
5025    tokio::spawn(async move {
5026        let result = tokio::task::spawn_blocking(move || {
5027            let run = sloc_core::read_json(&json_path)?;
5028            write_pdf_from_run(&run, &pdf_dest)
5029        })
5030        .await;
5031        let failed = match result {
5032            Ok(Ok(())) => false,
5033            Ok(Err(err)) => {
5034                eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5035                true
5036            }
5037            Err(err) => {
5038                eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5039                true
5040            }
5041        };
5042        if failed {
5043            let mut map = artifacts.lock().await;
5044            if let Some(entry) = map.get_mut(&run_id) {
5045                entry.pdf_path = None;
5046            }
5047        }
5048    });
5049}
5050
5051/// Sum the code lines added in this comparison (new + grown files).
5052fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5053    cmp.file_deltas
5054        .iter()
5055        .map(|f| match f.status {
5056            FileChangeStatus::Added => f.current_code,
5057            FileChangeStatus::Modified => f.code_delta.max(0),
5058            _ => 0,
5059        })
5060        .sum()
5061}
5062
5063/// Sum the code lines removed in this comparison (deleted + shrunk files).
5064fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5065    cmp.file_deltas
5066        .iter()
5067        .map(|f| match f.status {
5068            FileChangeStatus::Removed => f.baseline_code,
5069            FileChangeStatus::Modified => (-f.code_delta).max(0),
5070            _ => 0,
5071        })
5072        .sum()
5073}
5074
5075/// Sum the code lines present in both scans without any change (Unchanged files).
5076fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5077    cmp.file_deltas
5078        .iter()
5079        .filter(|f| f.status == FileChangeStatus::Unchanged)
5080        .map(|f| f.current_code)
5081        .sum()
5082}
5083
5084/// Sum the code lines residing in files that were modified between the two scans.
5085fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5086    cmp.file_deltas
5087        .iter()
5088        .filter(|f| f.status == FileChangeStatus::Modified)
5089        .map(|f| f.current_code)
5090        .sum()
5091}
5092
5093/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
5094fn build_submodule_row(
5095    s: &sloc_core::SubmoduleSummary,
5096    run: &AnalysisRun,
5097    run_id: &str,
5098    run_dir: &Path,
5099) -> SubmoduleRow {
5100    let safe = sanitize_project_label(&s.name);
5101    let artifact_key = format!("sub_{safe}");
5102    let pdf_artifact_key = format!("sub_{safe}_pdf");
5103    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5104        let parent_path = run
5105            .input_roots
5106            .first()
5107            .map_or("", std::string::String::as_str);
5108        let sub_run = build_sub_run(run, s, parent_path);
5109        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5110        render_sub_report_html(&sub_run, Some(&pdf_server_url))
5111            .ok()
5112            .and_then(|sub_html| {
5113                let sub_dir = run_dir.join("submodules");
5114                let _ = fs::create_dir_all(&sub_dir);
5115                let html_path = sub_dir.join(format!("{artifact_key}.html"));
5116                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5117                    // Pre-generate the sub-report PDF using the programmatic renderer
5118                    // so "View PDF" never needs to spawn Chrome for submodules.
5119                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5120                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
5121                    Some(format!("/runs/{artifact_key}/{run_id}"))
5122                } else {
5123                    None
5124                }
5125            })
5126    } else {
5127        None
5128    };
5129    SubmoduleRow {
5130        name: s.name.clone(),
5131        relative_path: s.relative_path.clone(),
5132        files_analyzed: s.files_analyzed,
5133        code_lines: s.code_lines,
5134        comment_lines: s.comment_lines,
5135        blank_lines: s.blank_lines,
5136        total_physical_lines: s.total_physical_lines,
5137        html_url,
5138    }
5139}
5140
5141// Immediately returns a wait page and runs the analysis in a background tokio task.
5142// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
5143#[allow(clippy::similar_names)]
5144#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
5145#[allow(clippy::too_many_lines)]
5146async fn analyze_handler(
5147    State(state): State<AppState>,
5148    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5149    Form(form): Form<AnalyzeForm>,
5150) -> impl IntoResponse {
5151    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5152        let template = ErrorTemplate {
5153            message: format!(
5154                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5155             Please wait a moment and try again."
5156            ),
5157            last_report_url: None,
5158            last_report_label: None,
5159            run_id: None,
5160            error_code: Some(503),
5161            csp_nonce: csp_nonce.clone(),
5162            version: env!("CARGO_PKG_VERSION"),
5163        };
5164        return (
5165            StatusCode::SERVICE_UNAVAILABLE,
5166            Html(
5167                template
5168                    .render()
5169                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5170            ),
5171        )
5172            .into_response();
5173    };
5174
5175    let mut config = state.base_config.clone();
5176
5177    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5178    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5179    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5180
5181    if !is_git_mode {
5182        let resolved_path = resolve_input_path(&form.path);
5183        if state.server_mode
5184            && !is_upload_tmp_path(&resolved_path)
5185            && !is_sample_path(&resolved_path)
5186        {
5187            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
5188                return resp;
5189            }
5190        }
5191        config.discovery.root_paths = vec![resolved_path];
5192    }
5193
5194    apply_form_to_config(&mut config, &form);
5195    apply_output_dir_exclusions(
5196        &mut config,
5197        &form.path,
5198        form.output_dir.as_deref().unwrap_or(""),
5199    );
5200
5201    // Generate a wait_id now (before spawning) so the client can poll for status.
5202    let wait_id = uuid::Uuid::new_v4().to_string();
5203    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5204
5205    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
5206    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5207    let task_cancel = Arc::clone(&cancel_token);
5208
5209    // Phase tracker: updated by run_analysis_task at key checkpoints.
5210    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5211    let task_phase = Arc::clone(&phase);
5212
5213    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5214    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5215    let task_files_done = Arc::clone(&files_done);
5216    let task_files_total = Arc::clone(&files_total);
5217
5218    // Register Running state before building the task struct so the semaphore permit
5219    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
5220    {
5221        let mut runs = state.async_runs.lock().await;
5222        runs.insert(
5223            wait_id.clone(),
5224            AsyncRunState::Running {
5225                started_at: std::time::Instant::now(),
5226                cancel_token,
5227                phase,
5228                files_done,
5229                files_total,
5230            },
5231        );
5232    }
5233
5234    let task = AnalysisTask {
5235        sem_permit,
5236        state: state.clone(),
5237        wait_id: wait_id.clone(),
5238        config,
5239        cancel: task_cancel,
5240        phase: task_phase,
5241        files_done: task_files_done,
5242        files_total: task_files_total,
5243        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5244        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5245        project_path: form.path.clone(),
5246        // In server mode the client-supplied output_dir is ignored — artifacts are
5247        // always written under the server's configured output root so remote users
5248        // cannot direct writes to arbitrary filesystem paths.
5249        output_dir: if state.server_mode {
5250            None
5251        } else {
5252            form.output_dir.clone()
5253        },
5254        clones_dir: state.git_clones_dir.clone(),
5255        cocomo_mode: form
5256            .cocomo_mode
5257            .clone()
5258            .unwrap_or_else(|| "organic".to_string()),
5259        complexity_alert: form
5260            .complexity_alert
5261            .as_deref()
5262            .and_then(|s| s.parse::<u32>().ok())
5263            .unwrap_or(0),
5264        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5265    };
5266
5267    tokio::spawn(run_analysis_task(task));
5268
5269    let template = ScanWaitTemplate {
5270        version: env!("CARGO_PKG_VERSION"),
5271        wait_id_json,
5272        project_path: form.path.clone(),
5273        csp_nonce,
5274    };
5275    let html = template
5276        .render()
5277        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5278    let mut response = Html(html).into_response();
5279    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
5280        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
5281            response.headers_mut().insert(name, val);
5282        }
5283    }
5284    response
5285}
5286
5287struct AnalysisTask {
5288    sem_permit: tokio::sync::OwnedSemaphorePermit,
5289    state: AppState,
5290    wait_id: String,
5291    config: AppConfig,
5292    cancel: Arc<std::sync::atomic::AtomicBool>,
5293    phase: Arc<std::sync::Mutex<String>>,
5294    files_done: Arc<std::sync::atomic::AtomicUsize>,
5295    files_total: Arc<std::sync::atomic::AtomicUsize>,
5296    git_repo: Option<String>,
5297    git_ref: Option<String>,
5298    project_path: String,
5299    output_dir: Option<String>,
5300    clones_dir: PathBuf,
5301    cocomo_mode: String,
5302    complexity_alert: u32,
5303    exclude_duplicates: bool,
5304}
5305
5306#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
5307async fn run_analysis_task(task: AnalysisTask) {
5308    let _permit = task.sem_permit;
5309
5310    let cancel_sb = Arc::clone(&task.cancel);
5311    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5312    let clones_dir_sb = task.clones_dir;
5313    // Save the upload staging path before config is moved into spawn_blocking.
5314    let upload_staging_root = task
5315        .config
5316        .discovery
5317        .root_paths
5318        .first()
5319        .filter(|p| is_upload_tmp_path(p))
5320        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5321        .map(PathBuf::from);
5322    let config_sb = task.config;
5323    let progress_sb = sloc_core::ProgressCounters {
5324        files_done: Arc::clone(&task.files_done),
5325        files_total: Arc::clone(&task.files_total),
5326    };
5327    if let Ok(mut p) = task.phase.lock() {
5328        *p = "Scanning files".to_string();
5329    }
5330    let analysis_result = tokio::task::spawn_blocking(move || {
5331        run_analysis_blocking(
5332            config_sb,
5333            git_repo_sb,
5334            git_ref_sb,
5335            clones_dir_sb,
5336            cancel_sb,
5337            Some(progress_sb),
5338        )
5339    })
5340    .await
5341    .map_err(|err| anyhow::anyhow!(err.to_string()))
5342    .and_then(|result| result);
5343
5344    if let Ok(mut p) = task.phase.lock() {
5345        *p = "Writing reports".to_string();
5346    }
5347
5348    // If cancelled while running, discard results and mark as cancelled.
5349    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5350        let mut runs = task.state.async_runs.lock().await;
5351        // Only overwrite if still Running (don't clobber a Complete that snuck in).
5352        if matches!(
5353            runs.get(&task.wait_id),
5354            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5355        ) {
5356            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5357        }
5358        drop(runs);
5359        return;
5360    }
5361
5362    let run = match analysis_result {
5363        Ok(v) => v,
5364        Err(err) => {
5365            // Distinguish user-cancelled from real failure.
5366            if err.to_string().contains("analysis cancelled") {
5367                let mut runs = task.state.async_runs.lock().await;
5368                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5369                drop(runs);
5370                return;
5371            }
5372            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5373            let mut runs = task.state.async_runs.lock().await;
5374            runs.insert(
5375                task.wait_id.clone(),
5376                AsyncRunState::Failed {
5377                    message: "Analysis failed. Check that the path exists and is readable."
5378                        .to_string(),
5379                },
5380            );
5381            drop(runs);
5382            return;
5383        }
5384    };
5385
5386    let run_id = run.tool.run_id.clone();
5387    tracing::info!(event = "scan_complete", run_id = %run_id,
5388        path = %task.project_path, files = run.summary_totals.files_analyzed,
5389        "Analysis finished");
5390
5391    let prev_entry: Option<RegistryEntry> = {
5392        let reg = task.state.registry.lock().await;
5393        reg.entries_for_roots(&run.input_roots)
5394            .into_iter()
5395            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5396            .cloned()
5397    };
5398
5399    let scan_delta = prev_entry.as_ref().and_then(|prev| {
5400        prev.json_path
5401            .as_ref()
5402            .and_then(|p| read_json(p).ok())
5403            .map(|prev_run| compute_delta(&prev_run, &run))
5404    });
5405    let prev_scan_count: usize = {
5406        let reg = task.state.registry.lock().await;
5407        reg.entries_for_roots(&run.input_roots)
5408            .iter()
5409            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5410            .count()
5411    };
5412
5413    // Build the HTML report now that delta is available, so the artifact
5414    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
5415    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5416        .as_ref()
5417        .zip(prev_entry.as_ref())
5418        .map(|(cmp, prev)| ReportDeltaContext {
5419            delta_code_added: sum_added_code_lines(cmp),
5420            delta_code_removed: sum_removed_code_lines(cmp),
5421            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5422            delta_files_added: cmp.files_added,
5423            delta_files_removed: cmp.files_removed,
5424            delta_files_modified: cmp.files_modified,
5425            delta_files_unchanged: cmp.files_unchanged,
5426            prev_code_lines: prev.summary.code_lines,
5427            prev_scan_count: prev_scan_count + 1,
5428            prev_scan_label: fmt_la_time(prev.timestamp_utc),
5429            prev_run_id: Some(prev.run_id.clone()),
5430            current_run_id: Some(run_id.clone()),
5431        });
5432    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5433        Ok(h) => h,
5434        Err(err) => {
5435            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5436            let mut runs = task.state.async_runs.lock().await;
5437            runs.insert(
5438                task.wait_id.clone(),
5439                AsyncRunState::Failed {
5440                    message: "Failed to render HTML report.".to_string(),
5441                },
5442            );
5443            drop(runs);
5444            return;
5445        }
5446    };
5447
5448    let output_root = resolve_output_root(task.output_dir.as_deref());
5449    let project_label = derive_project_label(
5450        task.git_repo.as_deref(),
5451        task.git_ref.as_deref(),
5452        &task.project_path,
5453    );
5454    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5455    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5456
5457    let result_context = RunResultContext {
5458        prev_entry: prev_entry.clone(),
5459        prev_scan_count,
5460        project_path: task.project_path.clone(),
5461        cocomo_mode: task.cocomo_mode.clone(),
5462        complexity_alert: task.complexity_alert,
5463        exclude_duplicates: task.exclude_duplicates,
5464    };
5465
5466    let artifact_result = persist_run_artifacts(
5467        &run,
5468        &report_html,
5469        &run_dir,
5470        &run.effective_configuration.reporting.report_title,
5471        &file_stem,
5472        result_context,
5473    );
5474
5475    let (artifacts, pending_pdf) = match artifact_result {
5476        Ok(v) => v,
5477        Err(err) => {
5478            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5479            let mut runs = task.state.async_runs.lock().await;
5480            runs.insert(
5481                task.wait_id.clone(),
5482                AsyncRunState::Failed {
5483                    message: "Failed to save report artifacts. Check available disk space."
5484                        .to_string(),
5485                },
5486            );
5487            drop(runs);
5488            return;
5489        }
5490    };
5491
5492    {
5493        let mut map = task.state.artifacts.lock().await;
5494        map.insert(run_id.clone(), artifacts.clone());
5495    }
5496
5497    {
5498        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5499        let mut reg = task.state.registry.lock().await;
5500        reg.add_entry(entry);
5501        let _ = reg.save(&task.state.registry_path);
5502    }
5503
5504    if let Some(ref cfg_path) = artifacts.scan_config_path {
5505        save_scan_config_json(
5506            cfg_path,
5507            &run,
5508            &task.project_path,
5509            task.output_dir.as_deref(),
5510            &task.cocomo_mode,
5511            task.complexity_alert,
5512            task.exclude_duplicates,
5513        );
5514    }
5515
5516    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5517
5518    prom_runs_total().inc();
5519
5520    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
5521    let mut runs = task.state.async_runs.lock().await;
5522    runs.insert(
5523        task.wait_id.clone(),
5524        AsyncRunState::Complete {
5525            run_id: run_id.clone(),
5526        },
5527    );
5528    drop(runs);
5529
5530    // Remove the client-upload staging directory after a successful scan so
5531    // that uploaded project files don't accumulate in the OS temp directory.
5532    if let Some(staging) = upload_staging_root {
5533        let _ = tokio::fs::remove_dir_all(staging).await;
5534    }
5535
5536    let _ = scan_delta;
5537}
5538
5539fn save_scan_config_json(
5540    cfg_path: &std::path::Path,
5541    run: &sloc_core::AnalysisRun,
5542    project_path: &str,
5543    output_dir: Option<&str>,
5544    cocomo_mode: &str,
5545    complexity_alert: u32,
5546    exclude_duplicates: bool,
5547) {
5548    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5549        .ok()
5550        .and_then(|v| v.as_str().map(String::from))
5551        .unwrap_or_else(|| "code_only".to_string());
5552    let behavior_str =
5553        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5554            .ok()
5555            .and_then(|v| v.as_str().map(String::from))
5556            .unwrap_or_else(|| "skip".to_string());
5557    let continuation_policy_str = serde_json::to_value(
5558        run.effective_configuration
5559            .analysis
5560            .continuation_line_policy,
5561    )
5562    .ok()
5563    .and_then(|v| v.as_str().map(String::from))
5564    .unwrap_or_else(default_each_physical_line);
5565    let blank_policy_str = serde_json::to_value(
5566        run.effective_configuration
5567            .analysis
5568            .blank_in_block_comment_policy,
5569    )
5570    .ok()
5571    .and_then(|v| v.as_str().map(String::from))
5572    .unwrap_or_else(default_count_as_comment);
5573    let scan_cfg = ScanConfig {
5574        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5575        path: project_path.to_string(),
5576        include_globs: run
5577            .effective_configuration
5578            .discovery
5579            .include_globs
5580            .join("\n"),
5581        exclude_globs: run
5582            .effective_configuration
5583            .discovery
5584            .exclude_globs
5585            .join("\n"),
5586        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5587        mixed_line_policy: policy_str,
5588        python_docstrings_as_comments: run
5589            .effective_configuration
5590            .analysis
5591            .python_docstrings_as_comments,
5592        generated_file_detection: run
5593            .effective_configuration
5594            .analysis
5595            .generated_file_detection,
5596        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5597        vendor_directory_detection: run
5598            .effective_configuration
5599            .analysis
5600            .vendor_directory_detection,
5601        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5602        binary_file_behavior: behavior_str,
5603        output_dir: output_dir.unwrap_or("").to_string(),
5604        report_title: run.effective_configuration.reporting.report_title.clone(),
5605        continuation_line_policy: continuation_policy_str,
5606        blank_in_block_comment_policy: blank_policy_str,
5607        count_compiler_directives: run
5608            .effective_configuration
5609            .analysis
5610            .count_compiler_directives,
5611        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5612        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5613        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5614        style_lang_scope: run
5615            .effective_configuration
5616            .analysis
5617            .style_lang_scope
5618            .clone(),
5619        coverage_file: run
5620            .effective_configuration
5621            .analysis
5622            .coverage_file
5623            .as_ref()
5624            .map(|p| p.display().to_string())
5625            .unwrap_or_default(),
5626        cocomo_mode: cocomo_mode.to_string(),
5627        complexity_alert,
5628        exclude_duplicates,
5629        activity_window: run
5630            .effective_configuration
5631            .analysis
5632            .activity_window_days
5633            .unwrap_or(0),
5634    };
5635    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5636        let _ = std::fs::write(cfg_path, json);
5637    }
5638}
5639
5640#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
5641fn run_analysis_blocking(
5642    mut config: AppConfig,
5643    git_repo: Option<String>,
5644    git_ref: Option<String>,
5645    clones_dir: PathBuf,
5646    cancel: Arc<std::sync::atomic::AtomicBool>,
5647    progress: Option<sloc_core::ProgressCounters>,
5648) -> Result<sloc_core::AnalysisRun> {
5649    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5650        let dest = git_clone_dest(&repo, &clones_dir);
5651        sloc_git::clone_or_fetch(&repo, &dest)?;
5652        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5653        sloc_git::create_worktree(&dest, &refname, &wt)?;
5654        config.discovery.root_paths = vec![wt.clone()];
5655        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5656        let _ = sloc_git::destroy_worktree(&dest, &wt);
5657        let mut run = run?;
5658        if run.git_branch.is_none() {
5659            run.git_branch = Some(refname);
5660        }
5661        return Ok(run);
5662    }
5663    analyze(&config, "serve", Some(&cancel), progress.as_ref())
5664}
5665
5666fn derive_project_label(
5667    git_repo: Option<&str>,
5668    git_ref: Option<&str>,
5669    fallback_path: &str,
5670) -> String {
5671    match (
5672        git_repo.filter(|s| !s.is_empty()),
5673        git_ref.filter(|s| !s.is_empty()),
5674    ) {
5675        (Some(repo), Some(refname)) => {
5676            let repo_name = repo
5677                .trim_end_matches('/')
5678                .trim_end_matches(".git")
5679                .rsplit('/')
5680                .next()
5681                .unwrap_or("repo");
5682            sanitize_project_label(&format!("{repo_name}_{refname}"))
5683        }
5684        _ => sanitize_project_label(fallback_path),
5685    }
5686}
5687
5688fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5689    let commit = commit_short.unwrap_or("").trim();
5690    if commit.is_empty() {
5691        project_label.to_string()
5692    } else {
5693        format!("{project_label}_{commit}")
5694    }
5695}
5696
5697// ── Async scan status + result handlers ──────────────────────────────────────
5698
5699#[derive(Serialize)]
5700#[serde(tag = "state", rename_all = "snake_case")]
5701enum AsyncRunStatusResponse {
5702    Running {
5703        elapsed_secs: u64,
5704        phase: String,
5705        files_done: u64,
5706        files_total: u64,
5707    },
5708    Complete {
5709        run_id: String,
5710    },
5711    Failed {
5712        message: String,
5713    },
5714    Cancelled,
5715}
5716
5717async fn async_run_status_handler(
5718    State(state): State<AppState>,
5719    AxumPath(wait_id): AxumPath<String>,
5720) -> Response {
5721    // wait_id comes from our own UUID generator; reject any structurally malformed value.
5722    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5723        return error::bad_request("invalid wait_id");
5724    }
5725    let run_state = {
5726        let runs = state.async_runs.lock().await;
5727        runs.get(&wait_id).cloned()
5728    };
5729    match run_state {
5730        None => error::not_found("run not found"),
5731        Some(AsyncRunState::Running {
5732            started_at,
5733            phase,
5734            files_done,
5735            files_total,
5736            ..
5737        }) => {
5738            // Treat runs older than 2 h as timed out (analysis should finish well under that).
5739            if started_at.elapsed() > std::time::Duration::from_hours(2) {
5740                let mut runs = state.async_runs.lock().await;
5741                runs.insert(
5742                    wait_id,
5743                    AsyncRunState::Failed {
5744                        message: "Analysis timed out after 2 hours.".to_string(),
5745                    },
5746                );
5747                drop(runs);
5748                return Json(AsyncRunStatusResponse::Failed {
5749                    message: "Analysis timed out after 2 hours.".to_string(),
5750                })
5751                .into_response();
5752            }
5753            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5754            Json(AsyncRunStatusResponse::Running {
5755                elapsed_secs: started_at.elapsed().as_secs(),
5756                phase: phase_str,
5757                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5758                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5759            })
5760            .into_response()
5761        }
5762        Some(AsyncRunState::Complete { run_id }) => {
5763            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5764        }
5765        Some(AsyncRunState::Failed { message }) => {
5766            Json(AsyncRunStatusResponse::Failed { message }).into_response()
5767        }
5768        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5769    }
5770}
5771
5772async fn cancel_run_handler(
5773    State(state): State<AppState>,
5774    AxumPath(wait_id): AxumPath<String>,
5775) -> Response {
5776    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5777        return error::bad_request("invalid wait_id");
5778    }
5779    let mut runs = state.async_runs.lock().await;
5780    let resp = match runs.get(&wait_id) {
5781        Some(AsyncRunState::Running { cancel_token, .. }) => {
5782            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5783            runs.insert(wait_id, AsyncRunState::Cancelled);
5784            StatusCode::OK.into_response()
5785        }
5786        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5787        _ => error::not_found("run not found"),
5788    };
5789    drop(runs);
5790    resp
5791}
5792
5793async fn async_run_result_handler(
5794    State(state): State<AppState>,
5795    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5796    AxumPath(run_id): AxumPath<String>,
5797) -> Response {
5798    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5799        return StatusCode::BAD_REQUEST.into_response();
5800    }
5801
5802    let artifacts = {
5803        let map = state.artifacts.lock().await;
5804        map.get(&run_id).cloned()
5805    };
5806    let artifacts = if let Some(a) = artifacts {
5807        a
5808    } else {
5809        let reg = state.registry.lock().await;
5810        if let Some(entry) = reg.find_by_run_id(&run_id) {
5811            recover_artifacts_from_registry(entry)
5812        } else {
5813            let html = ErrorTemplate {
5814                message: format!(
5815                    "Report not found. Run ID {} is not in the scan history.",
5816                    &run_id[..run_id.len().min(8)]
5817                ),
5818                last_report_url: Some("/view-reports".to_string()),
5819                last_report_label: Some("View Reports".to_string()),
5820                run_id: Some(run_id.clone()),
5821                error_code: Some(404),
5822                csp_nonce: csp_nonce.clone(),
5823                version: env!("CARGO_PKG_VERSION"),
5824            }
5825            .render()
5826            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5827            return (StatusCode::NOT_FOUND, Html(html)).into_response();
5828        }
5829    };
5830
5831    let json_path = if let Some(p) = &artifacts.json_path {
5832        p.clone()
5833    } else {
5834        let html = ErrorTemplate {
5835            message: "JSON result was not saved for this run.".to_string(),
5836            last_report_url: Some("/view-reports".to_string()),
5837            last_report_label: Some("View Reports".to_string()),
5838            run_id: Some(run_id.clone()),
5839            error_code: Some(404),
5840            csp_nonce: csp_nonce.clone(),
5841            version: env!("CARGO_PKG_VERSION"),
5842        }
5843        .render()
5844        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5845        return (StatusCode::NOT_FOUND, Html(html)).into_response();
5846    };
5847
5848    let Ok(run) = read_json(&json_path) else {
5849        let folder_hint = output_folder_hint(&json_path);
5850        let redirect_url = format!("/runs/result/{run_id}");
5851        return missing_scan_relocate_response(
5852            &format!(
5853                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
5854                 deleted. Browse to the folder containing your scan output to reconnect it.",
5855                json_path.display()
5856            ),
5857            &run_id,
5858            &folder_hint,
5859            &redirect_url,
5860            state.server_mode,
5861            &csp_nonce,
5862        );
5863    };
5864
5865    let confluence_configured = {
5866        let store = state.confluence.lock().await;
5867        store.is_configured()
5868    };
5869
5870    render_result_page(
5871        &run,
5872        &artifacts,
5873        &run_id,
5874        &csp_nonce,
5875        confluence_configured,
5876        state.server_mode,
5877    )
5878}
5879
5880/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
5881fn json_escape(s: &str) -> String {
5882    s.replace('\\', "\\\\").replace('"', "\\\"")
5883}
5884
5885/// Per-language line/symbol totals summed across every language in a run.
5886struct LangTotals {
5887    physical_lines: u64,
5888    code_lines: u64,
5889    comment_lines: u64,
5890    blank_lines: u64,
5891    mixed_lines: u64,
5892    functions: u64,
5893    classes: u64,
5894    variables: u64,
5895    imports: u64,
5896}
5897
5898fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5899    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5900        run.totals_by_language.iter().map(f).sum()
5901    };
5902    LangTotals {
5903        physical_lines: s(|r| r.total_physical_lines),
5904        code_lines: s(|r| r.code_lines),
5905        comment_lines: s(|r| r.comment_lines),
5906        blank_lines: s(|r| r.blank_lines),
5907        mixed_lines: s(|r| r.mixed_lines_separate),
5908        functions: s(|r| r.functions),
5909        classes: s(|r| r.classes),
5910        variables: s(|r| r.variables),
5911        imports: s(|r| r.imports),
5912    }
5913}
5914
5915/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
5916struct DeltaFields {
5917    prev_fa_str: String,
5918    prev_fs_str: String,
5919    prev_pl_str: String,
5920    prev_cl_str: String,
5921    prev_cml_str: String,
5922    prev_bl_str: String,
5923    delta_fa_str: String,
5924    delta_fa_class: String,
5925    delta_fs_str: String,
5926    delta_fs_class: String,
5927    delta_pl_str: String,
5928    delta_pl_class: String,
5929    delta_cl_str: String,
5930    delta_cl_class: String,
5931    delta_cml_str: String,
5932    delta_cml_class: String,
5933    delta_bl_str: String,
5934    delta_bl_class: String,
5935    delta_lines_added: Option<i64>,
5936    delta_lines_removed: Option<i64>,
5937    delta_lines_net_str: String,
5938    delta_lines_net_class: String,
5939}
5940
5941// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
5942// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
5943// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
5944// from those field names and obscure the 1:1 mapping.
5945#[allow(
5946    clippy::similar_names,
5947    reason = "locals mirror template-bound struct fields"
5948)]
5949fn compute_delta_fields(
5950    prev_entry: Option<&RegistryEntry>,
5951    totals: &LangTotals,
5952    files_analyzed: u64,
5953    files_skipped: u64,
5954    scan_delta: Option<&sloc_core::ScanComparison>,
5955) -> DeltaFields {
5956    let prev_sum = prev_entry.map(|e| &e.summary);
5957    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5958
5959    let (delta_fa_str, delta_fa_class) =
5960        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5961    let (delta_fs_str, delta_fs_class) =
5962        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5963    let (delta_pl_str, delta_pl_class) = summary_delta(
5964        totals.physical_lines,
5965        prev_sum.map(|s| s.total_physical_lines),
5966    );
5967    let (delta_cl_str, delta_cl_class) =
5968        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5969    let (delta_cml_str, delta_cml_class) =
5970        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5971    let (delta_bl_str, delta_bl_class) =
5972        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5973
5974    let delta_lines_added = scan_delta.map(sum_added_code_lines);
5975    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5976    let (delta_lines_net_str, delta_lines_net_class) =
5977        match (delta_lines_added, delta_lines_removed) {
5978            (Some(a), Some(r)) => {
5979                let net = a - r;
5980                (fmt_delta(net), delta_class(net).to_string())
5981            }
5982            _ => ("\u{2014}".to_string(), "na".to_string()),
5983        };
5984
5985    DeltaFields {
5986        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5987        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5988        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5989        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5990        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5991        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5992        delta_fa_str,
5993        delta_fa_class: delta_fa_class.to_string(),
5994        delta_fs_str,
5995        delta_fs_class: delta_fs_class.to_string(),
5996        delta_pl_str,
5997        delta_pl_class: delta_pl_class.to_string(),
5998        delta_cl_str,
5999        delta_cl_class: delta_cl_class.to_string(),
6000        delta_cml_str,
6001        delta_cml_class: delta_cml_class.to_string(),
6002        delta_bl_str,
6003        delta_bl_class: delta_bl_class.to_string(),
6004        delta_lines_added,
6005        delta_lines_removed,
6006        delta_lines_net_str,
6007        delta_lines_net_class,
6008    }
6009}
6010
6011/// Count of unchanged code lines in a scan comparison.
6012fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6013    scan_delta
6014        .file_deltas
6015        .iter()
6016        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6017        .map(|f| {
6018            #[allow(clippy::cast_sign_loss)]
6019            let n = f.current_code as u64;
6020            n
6021        })
6022        .sum()
6023}
6024
6025fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6026    run.git_remote_url
6027        .as_deref()
6028        .zip(run.git_commit_long.as_deref())
6029        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6030}
6031
6032fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6033    run.git_remote_url
6034        .as_deref()
6035        .zip(run.git_branch.as_deref())
6036        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6037}
6038
6039fn scan_performed_by(run: &AnalysisRun) -> String {
6040    run.environment.ci_name.clone().unwrap_or_else(|| {
6041        format!(
6042            "{} / {}",
6043            run.environment.initiator_username, run.environment.initiator_hostname
6044        )
6045    })
6046}
6047
6048/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
6049fn build_lang_chart_json(run: &AnalysisRun) -> String {
6050    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6051    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6052    let entries: Vec<String> = langs
6053        .into_iter()
6054        .take(12)
6055        .map(|l| {
6056            let name = json_escape(l.language.display_name());
6057            format!(
6058                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6059                name,
6060                l.code_lines,
6061                l.comment_lines,
6062                l.blank_lines,
6063                l.total_physical_lines,
6064                l.functions,
6065                l.classes,
6066                l.variables,
6067                l.imports,
6068                l.files,
6069            )
6070        })
6071        .collect();
6072    format!("[{}]", entries.join(","))
6073}
6074
6075/// Per-language files-vs-lines points as a JSON array for the scatter chart.
6076fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6077    let entries: Vec<String> = run
6078        .totals_by_language
6079        .iter()
6080        .map(|l| {
6081            let name = json_escape(l.language.display_name());
6082            format!(
6083                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6084                name, l.files, l.code_lines, l.total_physical_lines,
6085            )
6086        })
6087        .collect();
6088    format!("[{}]", entries.join(","))
6089}
6090
6091/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
6092fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6093    let entries: Vec<String> = run
6094        .totals_by_language
6095        .iter()
6096        .filter(|l| {
6097            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6098        })
6099        .map(|l| {
6100            let name = json_escape(l.language.display_name());
6101            format!(
6102                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6103                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6104            )
6105        })
6106        .collect();
6107    format!("[{}]", entries.join(","))
6108}
6109
6110/// Per-submodule line counts as a JSON array for the submodule chart.
6111fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6112    let entries: Vec<String> = run
6113        .submodule_summaries
6114        .iter()
6115        .map(|s| {
6116            let name = json_escape(&s.name);
6117            format!(
6118                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6119                name,
6120                s.code_lines,
6121                s.comment_lines,
6122                s.blank_lines,
6123                s.total_physical_lines,
6124                s.files_analyzed,
6125            )
6126        })
6127        .collect();
6128    format!("[{}]", entries.join(","))
6129}
6130
6131/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
6132#[allow(clippy::cast_precision_loss)]
6133fn cov_pct_str(hit: u64, found: u64) -> String {
6134    if found > 0 {
6135        format!("{:.1}", hit as f64 / found as f64 * 100.0)
6136    } else {
6137        String::new()
6138    }
6139}
6140
6141/// `hit / found` summary string, or empty when nothing was found.
6142fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6143    if found > 0 {
6144        format!("{hit} / {found}")
6145    } else {
6146        String::new()
6147    }
6148}
6149
6150const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6151    use sloc_core::CocomoMode;
6152    match mode {
6153        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6154        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6155        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6156    }
6157}
6158
6159const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6160    use sloc_core::CocomoMode;
6161    match mode {
6162        CocomoMode::Organic => "Organic",
6163        CocomoMode::SemiDetached => "Semi-detached",
6164        CocomoMode::Embedded => "Embedded",
6165    }
6166}
6167
6168const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6169    use sloc_core::CocomoMode;
6170    match mode {
6171        CocomoMode::Organic => {
6172            "Organic: A small team working on a well-understood project in a familiar \
6173             environment with minimal external constraints. Suited for internal tools, \
6174             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6175        }
6176        CocomoMode::SemiDetached => {
6177            "Semi-detached: A mixed team with varying experience tackling a project with \
6178             moderate novelty and some rigid constraints. Typical for compilers, transaction \
6179             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6180        }
6181        CocomoMode::Embedded => {
6182            "Embedded: Tight hardware, software, or operational constraints requiring \
6183             significant innovation and deep integration work. Typical for real-time control \
6184             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6185        }
6186    }
6187}
6188
6189/// COCOMO display strings recomputed for the scan-wizard-selected mode.
6190struct CocomoFields {
6191    has_cocomo: bool,
6192    effort_str: String,
6193    duration_str: String,
6194    staff_str: String,
6195    ksloc_str: String,
6196    mode_label: String,
6197    mode_tooltip: String,
6198}
6199
6200#[allow(clippy::cast_precision_loss)]
6201fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6202    use sloc_core::CocomoMode;
6203    let mode = match mode_str {
6204        "semi_detached" => CocomoMode::SemiDetached,
6205        "embedded" => CocomoMode::Embedded,
6206        _ => CocomoMode::Organic,
6207    };
6208    let (a, b, c, d) = cocomo_coefficients(mode);
6209    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6210    let effort = a * ksloc.powf(b);
6211    let duration = c * effort.powf(d);
6212    let staff = if duration > 0.0 {
6213        effort / duration
6214    } else {
6215        0.0
6216    };
6217    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6218    let mode_label = cocomo_mode_label(mode).to_string();
6219    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6220    if run.summary_totals.code_lines > 0 {
6221        CocomoFields {
6222            has_cocomo: true,
6223            effort_str: round2(effort),
6224            duration_str: round2(duration),
6225            staff_str: round2(staff),
6226            ksloc_str: round2(ksloc),
6227            mode_label,
6228            mode_tooltip,
6229        }
6230    } else {
6231        CocomoFields {
6232            has_cocomo: false,
6233            effort_str: String::new(),
6234            duration_str: String::new(),
6235            staff_str: String::new(),
6236            ksloc_str: String::new(),
6237            mode_label,
6238            mode_tooltip,
6239        }
6240    }
6241}
6242
6243#[allow(clippy::too_many_lines)]
6244#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
6245#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
6246fn render_result_page(
6247    run: &AnalysisRun,
6248    artifacts: &RunArtifacts,
6249    run_id: &str,
6250    csp_nonce: &str,
6251    confluence_configured: bool,
6252    server_mode: bool,
6253) -> Response {
6254    let ctx = &artifacts.result_context;
6255    let prev_entry = &ctx.prev_entry;
6256    let prev_scan_count = ctx.prev_scan_count;
6257    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
6258    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
6259    // field is never blank.
6260    let project_path_owned = if ctx.project_path.is_empty() {
6261        run.input_roots.join(", ")
6262    } else {
6263        ctx.project_path.clone()
6264    };
6265    let project_path = &project_path_owned;
6266
6267    let scan_delta = prev_entry.as_ref().and_then(|prev| {
6268        prev.json_path
6269            .as_ref()
6270            .and_then(|p| read_json(p).ok())
6271            .map(|prev_run| compute_delta(&prev_run, run))
6272    });
6273
6274    let files_analyzed = run.per_file_records.len() as u64;
6275    let files_skipped = run.skipped_file_records.len() as u64;
6276    let totals = sum_lang_totals(run);
6277
6278    let DeltaFields {
6279        prev_fa_str,
6280        prev_fs_str,
6281        prev_pl_str,
6282        prev_cl_str,
6283        prev_cml_str,
6284        prev_bl_str,
6285        delta_fa_str,
6286        delta_fa_class,
6287        delta_fs_str,
6288        delta_fs_class,
6289        delta_pl_str,
6290        delta_pl_class,
6291        delta_cl_str,
6292        delta_cl_class,
6293        delta_cml_str,
6294        delta_cml_class,
6295        delta_bl_str,
6296        delta_bl_class,
6297        delta_lines_added,
6298        delta_lines_removed,
6299        delta_lines_net_str,
6300        delta_lines_net_class,
6301    } = compute_delta_fields(
6302        prev_entry.as_ref(),
6303        &totals,
6304        files_analyzed,
6305        files_skipped,
6306        scan_delta.as_ref(),
6307    );
6308
6309    let run_dir = artifacts.output_dir.clone();
6310    let git_branch = run.git_branch.clone();
6311    let git_commit = run.git_commit_short.clone();
6312    let git_commit_long = run.git_commit_long.clone();
6313    let git_author = run.git_commit_author.clone();
6314    let git_commit_url = git_commit_url_for(run);
6315    let git_branch_url = git_branch_url_for(run);
6316    let scan_performed_by = scan_performed_by(run);
6317    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6318    let os_display = format!(
6319        "{} / {}",
6320        run.environment.operating_system, run.environment.architecture
6321    );
6322    let test_count = run.summary_totals.test_count;
6323
6324    // ── New metrics ──────────────────────────────────────────────────────────
6325    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6326    let lsloc = run.summary_totals.lsloc;
6327    let uloc = run.uloc;
6328    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6329    let duplicate_group_count = run.duplicate_groups.len();
6330
6331    // Re-compute COCOMO with the mode selected in the scan wizard.
6332    let ctx = &artifacts.result_context;
6333    let CocomoFields {
6334        has_cocomo,
6335        effort_str: cocomo_effort_str,
6336        duration_str: cocomo_duration_str,
6337        staff_str: cocomo_staff_str,
6338        ksloc_str: cocomo_ksloc_str,
6339        mode_label: cocomo_mode_label,
6340        mode_tooltip: cocomo_mode_tooltip,
6341    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6342    let complexity_alert = ctx.complexity_alert;
6343
6344    let template = ResultTemplate {
6345        version: env!("CARGO_PKG_VERSION"),
6346        report_title: run.effective_configuration.reporting.report_title.clone(),
6347        project_path: project_path.clone(),
6348        output_dir: display_path(&artifacts.output_dir),
6349        run_id: run_id.to_owned(),
6350        run_id_short: run_id
6351            .split('-')
6352            .next_back()
6353            .unwrap_or(run_id)
6354            .chars()
6355            .take(7)
6356            .collect(),
6357        files_analyzed,
6358        files_skipped,
6359        physical_lines: totals.physical_lines,
6360        code_lines: totals.code_lines,
6361        comment_lines: totals.comment_lines,
6362        blank_lines: totals.blank_lines,
6363        mixed_lines: totals.mixed_lines,
6364        functions: totals.functions,
6365        classes: totals.classes,
6366        variables: totals.variables,
6367        imports: totals.imports,
6368        html_url: artifacts
6369            .html_path
6370            .as_ref()
6371            .map(|_| format!("/runs/html/{run_id}")),
6372        pdf_url: artifacts
6373            .pdf_path
6374            .as_ref()
6375            .map(|_| format!("/runs/pdf/{run_id}")),
6376        json_url: artifacts
6377            .json_path
6378            .as_ref()
6379            .map(|_| format!("/runs/json/{run_id}")),
6380        html_download_url: artifacts
6381            .html_path
6382            .as_ref()
6383            .map(|_| format!("/runs/html/{run_id}?download=1")),
6384        pdf_download_url: artifacts
6385            .pdf_path
6386            .as_ref()
6387            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6388        json_download_url: artifacts
6389            .json_path
6390            .as_ref()
6391            .map(|_| format!("/runs/json/{run_id}?download=1")),
6392        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6393        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6394        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6395        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6396        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6397        prev_fa_str,
6398        prev_fs_str,
6399        prev_pl_str,
6400        prev_cl_str,
6401        prev_cml_str,
6402        prev_bl_str,
6403        delta_fa_str,
6404        delta_fa_class,
6405        delta_fs_str,
6406        delta_fs_class,
6407        delta_pl_str,
6408        delta_pl_class,
6409        delta_cl_str,
6410        delta_cl_class,
6411        delta_cml_str,
6412        delta_cml_class,
6413        delta_bl_str,
6414        delta_bl_class,
6415        delta_lines_added,
6416        delta_lines_removed,
6417        delta_lines_net_str,
6418        delta_lines_net_class,
6419        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6420        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6421        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6422        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6423        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6424        git_branch,
6425        git_branch_url,
6426        git_commit,
6427        git_commit_long,
6428        git_author,
6429        git_commit_url,
6430        scan_performed_by,
6431        scan_time_display,
6432        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6433        os_display,
6434        test_count,
6435        test_assertion_count: run.summary_totals.test_assertion_count,
6436        current_scan_number: prev_scan_count + 1,
6437        prev_scan_count,
6438        submodule_rows: run
6439            .submodule_summaries
6440            .iter()
6441            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6442            .collect(),
6443        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6444        scan_config_url: format!("/runs/scan-config/{run_id}"),
6445        lang_chart_json: build_lang_chart_json(run),
6446        scatter_chart_json: build_scatter_chart_json(run),
6447        semantic_chart_json: build_semantic_chart_json(run),
6448        submodule_chart_json: build_submodule_chart_json(run),
6449        has_submodule_data: !run.submodule_summaries.is_empty(),
6450        has_semantic_data: run
6451            .totals_by_language
6452            .iter()
6453            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6454        csp_nonce: csp_nonce.to_owned(),
6455        confluence_configured,
6456        server_mode,
6457        report_header_footer: run
6458            .effective_configuration
6459            .reporting
6460            .report_header_footer
6461            .clone(),
6462        is_offline: false,
6463        cyclomatic_complexity,
6464        lsloc,
6465        uloc,
6466        dryness_pct_str,
6467        duplicate_group_count,
6468        has_cocomo,
6469        cocomo_effort_str,
6470        cocomo_duration_str,
6471        cocomo_staff_str,
6472        cocomo_ksloc_str,
6473        cocomo_mode_label,
6474        cocomo_mode_tooltip,
6475        complexity_alert,
6476        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6477        cov_line_pct: cov_pct_str(
6478            run.summary_totals.coverage_lines_hit,
6479            run.summary_totals.coverage_lines_found,
6480        ),
6481        cov_fn_pct: cov_pct_str(
6482            run.summary_totals.coverage_functions_hit,
6483            run.summary_totals.coverage_functions_found,
6484        ),
6485        cov_branch_pct: cov_pct_str(
6486            run.summary_totals.coverage_branches_hit,
6487            run.summary_totals.coverage_branches_found,
6488        ),
6489        cov_lines_summary: cov_lines_summary_str(
6490            run.summary_totals.coverage_lines_hit,
6491            run.summary_totals.coverage_lines_found,
6492        ),
6493    };
6494
6495    Html(
6496        template
6497            .render()
6498            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6499    )
6500    .into_response()
6501}
6502
6503fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6504    let slug: String = report_title
6505        .chars()
6506        .map(|c| {
6507            if c.is_alphanumeric() || c == '-' {
6508                c.to_ascii_lowercase()
6509            } else {
6510                '_'
6511            }
6512        })
6513        .collect::<String>()
6514        .split('_')
6515        .filter(|s| !s.is_empty())
6516        .collect::<Vec<_>>()
6517        .join("_");
6518
6519    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6520
6521    if slug.is_empty() {
6522        format!("report_{short_id}.pdf")
6523    } else {
6524        format!("{slug}_{short_id}.pdf")
6525    }
6526}
6527
6528#[derive(Serialize)]
6529struct PdfStatusResponse {
6530    ready: bool,
6531}
6532
6533/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
6534/// Clients poll this to update the button state without page reloads.
6535async fn pdf_status_handler(
6536    State(state): State<AppState>,
6537    AxumPath(run_id): AxumPath<String>,
6538) -> Response {
6539    let pdf_path = {
6540        let registry = state.artifacts.lock().await;
6541        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6542    };
6543    let pdf_path = if pdf_path.is_some() {
6544        pdf_path
6545    } else {
6546        let reg = state.registry.lock().await;
6547        reg.find_by_run_id(&run_id)
6548            .map(recover_artifacts_from_registry)
6549            .and_then(|a| a.pdf_path)
6550    };
6551    let ready = pdf_path.is_some_and(|p| p.exists());
6552    Json(PdfStatusResponse { ready }).into_response()
6553}
6554
6555/// GET /`api/runs/:run_id/bundle`
6556///
6557/// Streams a gzip-compressed tar archive containing every artifact in the run's
6558/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
6559/// is built in memory so it never touches a temp file.
6560async fn download_bundle_handler(
6561    State(state): State<AppState>,
6562    AxumPath(run_id): AxumPath<String>,
6563) -> Response {
6564    // Resolve output directory from in-memory cache or persisted registry.
6565    let output_dir = {
6566        let cache = state.artifacts.lock().await;
6567        cache.get(&run_id).map(|a| a.output_dir.clone())
6568    };
6569    let output_dir = if let Some(d) = output_dir {
6570        d
6571    } else {
6572        let reg = state.registry.lock().await;
6573        match reg.find_by_run_id(&run_id) {
6574            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6575            None => {
6576                return (
6577                    StatusCode::NOT_FOUND,
6578                    Json(serde_json::json!({"error": "Run not found"})),
6579                )
6580                    .into_response();
6581            }
6582        }
6583    };
6584
6585    if !output_dir.exists() {
6586        return (
6587            StatusCode::NOT_FOUND,
6588            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6589        )
6590            .into_response();
6591    }
6592
6593    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
6594    let run_id_clone = run_id.clone();
6595    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6596        use flate2::{write::GzEncoder, Compression};
6597        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6598        {
6599            let mut tar = tar::Builder::new(&mut enc);
6600            tar.follow_symlinks(false);
6601            // Append every regular file in the output directory, skipping
6602            // sub-directories (the output dir is always flat).
6603            if let Ok(entries) = std::fs::read_dir(&output_dir) {
6604                for entry in entries.filter_map(Result::ok) {
6605                    let p = entry.path();
6606                    if p.is_file() {
6607                        let name = p.file_name().unwrap_or_default().to_string_lossy();
6608                        let archive_path = format!("{run_id_clone}/{name}");
6609                        tar.append_path_with_name(&p, &archive_path)?;
6610                    }
6611                }
6612            }
6613            tar.finish()?;
6614        }
6615        Ok(enc.finish()?)
6616    })
6617    .await;
6618
6619    match archive_result {
6620        Ok(Ok(bytes)) => {
6621            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6622            axum::response::Response::builder()
6623                .status(StatusCode::OK)
6624                .header("Content-Type", "application/gzip")
6625                .header(
6626                    "Content-Disposition",
6627                    format!("attachment; filename=\"{filename}\""),
6628                )
6629                .header("Content-Length", bytes.len().to_string())
6630                .body(axum::body::Body::from(bytes))
6631                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6632        }
6633        Ok(Err(e)) => (
6634            StatusCode::INTERNAL_SERVER_ERROR,
6635            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6636        )
6637            .into_response(),
6638        Err(e) => (
6639            StatusCode::INTERNAL_SERVER_ERROR,
6640            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6641        )
6642            .into_response(),
6643    }
6644}
6645
6646/// DELETE /`api/runs/:run_id`
6647///
6648/// Removes all on-disk artifacts for the run and purges the run from the
6649/// in-memory cache and the persisted registry. Returns 204 on success.
6650async fn delete_run_handler(
6651    State(state): State<AppState>,
6652    AxumPath(run_id): AxumPath<String>,
6653) -> Response {
6654    // Resolve output directory.
6655    let output_dir = {
6656        let mut cache = state.artifacts.lock().await;
6657        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6658        cache.remove(&run_id);
6659        dir
6660    };
6661    let output_dir = if let Some(d) = output_dir {
6662        d
6663    } else {
6664        let reg = state.registry.lock().await;
6665        reg.find_by_run_id(&run_id)
6666            .map(|e| recover_artifacts_from_registry(e).output_dir)
6667            .unwrap_or_default()
6668    };
6669
6670    // Remove from persisted registry.
6671    {
6672        let mut reg = state.registry.lock().await;
6673        reg.entries.retain(|e| e.run_id != run_id);
6674        let _ = reg.save(&state.registry_path);
6675    }
6676
6677    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
6678    // a prior delete may have already removed the directory.
6679    if output_dir.exists() {
6680        match tokio::fs::remove_dir_all(&output_dir).await {
6681            Ok(()) => {}
6682            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6683            Err(e) => {
6684                return (
6685                    StatusCode::INTERNAL_SERVER_ERROR,
6686                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6687                )
6688                    .into_response();
6689            }
6690        }
6691    }
6692
6693    StatusCode::NO_CONTENT.into_response()
6694}
6695
6696/// POST /api/runs/cleanup
6697///
6698/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
6699/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
6700async fn cleanup_runs_handler(
6701    State(state): State<AppState>,
6702    Json(body): Json<serde_json::Value>,
6703) -> Response {
6704    let days = body
6705        .get("older_than_days")
6706        .and_then(serde_json::Value::as_u64)
6707        .unwrap_or(30)
6708        .max(1);
6709
6710    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6711
6712    // Collect expired entries from the registry.
6713    let expired: Vec<(String, PathBuf)> = {
6714        let reg = state.registry.lock().await;
6715        reg.entries
6716            .iter()
6717            .filter(|e| e.timestamp_utc < cutoff)
6718            .map(|e| {
6719                let arts = recover_artifacts_from_registry(e);
6720                (e.run_id.clone(), arts.output_dir)
6721            })
6722            .collect()
6723    };
6724
6725    let mut deleted = 0usize;
6726    for (run_id, output_dir) in &expired {
6727        // Remove from in-memory cache.
6728        state.artifacts.lock().await.remove(run_id);
6729        // Delete on-disk artifacts (non-fatal if already gone).
6730        if output_dir.exists() {
6731            if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6732                eprintln!(
6733                    "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6734                    output_dir.display()
6735                );
6736                continue;
6737            }
6738        }
6739        deleted += 1;
6740    }
6741
6742    // Purge expired run IDs from the registry in one pass.
6743    let expired_ids: std::collections::HashSet<&str> =
6744        expired.iter().map(|(id, _)| id.as_str()).collect();
6745    {
6746        let mut reg = state.registry.lock().await;
6747        reg.entries
6748            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6749        let _ = reg.save(&state.registry_path);
6750    }
6751
6752    Json(serde_json::json!({ "deleted": deleted })).into_response()
6753}
6754
6755/// Spawns the background auto-cleanup task. Returns a handle so the caller can
6756/// abort it when the policy is updated or disabled.
6757fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6758    tokio::spawn(async move {
6759        loop {
6760            let interval_secs = {
6761                let store = state.cleanup_policy.lock().await;
6762                match &store.policy {
6763                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6764                    _ => break,
6765                }
6766            };
6767            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6768            let n = run_auto_cleanup(&state).await;
6769            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6770        }
6771    })
6772}
6773
6774fn collect_runs_to_delete(
6775    reg: &ScanRegistry,
6776    max_age_days: Option<u32>,
6777    max_run_count: Option<u32>,
6778) -> std::collections::HashSet<String> {
6779    let mut to_delete = std::collections::HashSet::new();
6780    if let Some(days) = max_age_days {
6781        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6782        for e in &reg.entries {
6783            if e.timestamp_utc < cutoff {
6784                to_delete.insert(e.run_id.clone());
6785            }
6786        }
6787    }
6788    if let Some(max_count) = max_run_count {
6789        // entries are sorted newest-first; skip the ones we keep
6790        for e in reg.entries.iter().skip(max_count as usize) {
6791            to_delete.insert(e.run_id.clone());
6792        }
6793    }
6794    to_delete
6795}
6796
6797async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6798    let output_dir = {
6799        let mut cache = state.artifacts.lock().await;
6800        let d = cache.get(run_id).map(|a| a.output_dir.clone());
6801        cache.remove(run_id);
6802        d
6803    };
6804    let output_dir = if let Some(d) = output_dir {
6805        d
6806    } else {
6807        let reg = state.registry.lock().await;
6808        reg.find_by_run_id(run_id)
6809            .map(|e| recover_artifacts_from_registry(e).output_dir)
6810            .unwrap_or_default()
6811    };
6812    if output_dir.exists() {
6813        let _ = tokio::fs::remove_dir_all(&output_dir).await;
6814    }
6815}
6816
6817/// Core cleanup logic shared by the background task and the "Run Now" handler.
6818/// Applies both the age limit and the count limit, then updates `last_run_at`.
6819/// Returns the number of runs deleted.
6820async fn run_auto_cleanup(state: &AppState) -> u32 {
6821    let (max_age_days, max_run_count) = {
6822        let store = state.cleanup_policy.lock().await;
6823        match &store.policy {
6824            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6825            _ => return 0,
6826        }
6827    };
6828
6829    let to_delete = {
6830        let reg = state.registry.lock().await;
6831        collect_runs_to_delete(&reg, max_age_days, max_run_count)
6832    };
6833
6834    for run_id in &to_delete {
6835        delete_run_artifacts(state, run_id).await;
6836    }
6837
6838    // Purge from registry.
6839    if !to_delete.is_empty() {
6840        let mut reg = state.registry.lock().await;
6841        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6842        let _ = reg.save(&state.registry_path);
6843    }
6844
6845    let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6846    {
6847        let mut store = state.cleanup_policy.lock().await;
6848        store.last_run_at = Some(chrono::Utc::now());
6849        store.last_run_deleted = Some(deleted);
6850        let _ = store.save(&state.cleanup_policy_path);
6851    }
6852    deleted
6853}
6854
6855// ── Auto-cleanup policy API ───────────────────────────────────────────────────
6856
6857/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
6858async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6859    let store = state.cleanup_policy.lock().await;
6860    Json(serde_json::json!({
6861        "policy": store.policy,
6862        "last_run_at": store.last_run_at,
6863        "last_run_deleted": store.last_run_deleted,
6864    }))
6865    .into_response()
6866}
6867
6868/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
6869async fn api_save_cleanup_policy(
6870    State(state): State<AppState>,
6871    Json(body): Json<CleanupPolicy>,
6872) -> Response {
6873    // Abort any running task so the new interval takes effect immediately.
6874    {
6875        let mut handle = state.cleanup_task_handle.lock().await;
6876        if let Some(h) = handle.take() {
6877            h.abort();
6878        }
6879    }
6880    {
6881        let mut store = state.cleanup_policy.lock().await;
6882        store.policy = Some(body.clone());
6883        if let Err(e) = store.save(&state.cleanup_policy_path) {
6884            return (
6885                StatusCode::INTERNAL_SERVER_ERROR,
6886                Json(serde_json::json!({"error": e.to_string()})),
6887            )
6888                .into_response();
6889        }
6890    }
6891    if body.enabled {
6892        let handle = spawn_cleanup_policy_task(state.clone());
6893        *state.cleanup_task_handle.lock().await = Some(handle);
6894    }
6895    StatusCode::NO_CONTENT.into_response()
6896}
6897
6898/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
6899async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6900    let deleted = run_auto_cleanup(&state).await;
6901    Json(serde_json::json!({ "deleted": deleted })).into_response()
6902}
6903
6904/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
6905async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6906    {
6907        let mut handle = state.cleanup_task_handle.lock().await;
6908        if let Some(h) = handle.take() {
6909            h.abort();
6910        }
6911    }
6912    {
6913        let mut store = state.cleanup_policy.lock().await;
6914        store.policy = None;
6915        let _ = store.save(&state.cleanup_policy_path);
6916    }
6917    StatusCode::NO_CONTENT.into_response()
6918}
6919
6920/// Serve the HTML artifact for a run — view or download.
6921/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
6922/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
6923/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
6924/// Only called for browser views; downloads keep the self-contained inline version.
6925fn swap_inline_chart_js_for_static(html: String) -> String {
6926    let Some(head_end) = html.find("</head>") else {
6927        return html;
6928    };
6929    let Some(script_start) = html[..head_end].rfind("<script") else {
6930        return html;
6931    };
6932    let Some(close_offset) = html[script_start..].find("</script>") else {
6933        return html;
6934    };
6935    let block_end = script_start + close_offset + "</script>".len();
6936    format!(
6937        "{}<script src=\"/static/chart-report.js\"></script>{}",
6938        &html[..script_start],
6939        &html[block_end..]
6940    )
6941}
6942
6943/// current-request Content-Security-Policy nonce check.
6944fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6945    // Find the first nonce value that was baked in at render time.
6946    let Some(start) = html.find("nonce=\"") else {
6947        // Reports generated before nonce support was added have bare <style> and <script>
6948        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
6949        // the inline blocks — without it the browser blocks all CSS and JS.
6950        return html
6951            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6952            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6953    };
6954    let value_start = start + 7; // len(r#"nonce=""#) == 7
6955    let Some(end_offset) = html[value_start..].find('"') else {
6956        return html.to_owned();
6957    };
6958    let old_nonce = &html[value_start..value_start + end_offset];
6959    html.replace(
6960        &format!("nonce=\"{old_nonce}\""),
6961        &format!("nonce=\"{new_nonce}\""),
6962    )
6963}
6964
6965fn serve_html_artifact(
6966    path: &Path,
6967    wants_download: bool,
6968    csp_nonce: &str,
6969    run_id: &str,
6970    server_mode: bool,
6971) -> Response {
6972    match fs::read_to_string(path) {
6973        Ok(raw) => {
6974            // Patch the saved nonce so inline styles/scripts pass CSP.
6975            let content = patch_html_nonce(&raw, csp_nonce);
6976            if wants_download {
6977                // Keep the self-contained inline version for downloads (opened as file://).
6978                (
6979                    [
6980                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6981                        (
6982                            header::CONTENT_DISPOSITION,
6983                            "attachment; filename=report.html",
6984                        ),
6985                    ],
6986                    content,
6987                )
6988                    .into_response()
6989            } else {
6990                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
6991                // browser caches it after the first view; the HTML response also shrinks.
6992                Html(swap_inline_chart_js_for_static(content)).into_response()
6993            }
6994        }
6995        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6996            let filename = path.file_name().map_or_else(
6997                || "report.html".to_string(),
6998                |n| n.to_string_lossy().into_owned(),
6999            );
7000            let html = LocateFileTemplate {
7001                run_id: run_id.to_owned(),
7002                artifact_type: "html".to_string(),
7003                expected_filename: filename,
7004                server_mode,
7005                csp_nonce: csp_nonce.to_owned(),
7006                version: env!("CARGO_PKG_VERSION"),
7007            }
7008            .render()
7009            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7010            (StatusCode::NOT_FOUND, Html(html)).into_response()
7011        }
7012        Err(err) => {
7013            let filename = path.file_name().map_or_else(
7014                || "report.html".to_string(),
7015                |n| n.to_string_lossy().into_owned(),
7016            );
7017            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7018            let html = ErrorTemplate {
7019                message: msg,
7020                last_report_url: Some("/view-reports".to_string()),
7021                last_report_label: Some("View Reports".to_string()),
7022                run_id: None,
7023                error_code: Some(404),
7024                csp_nonce: csp_nonce.to_owned(),
7025                version: env!("CARGO_PKG_VERSION"),
7026            }
7027            .render()
7028            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7029            (StatusCode::NOT_FOUND, Html(html)).into_response()
7030        }
7031    }
7032}
7033
7034/// Serve the PDF artifact for a run — inline or download.
7035fn serve_pdf_artifact(
7036    path: &Path,
7037    report_title: &str,
7038    run_id: &str,
7039    wants_download: bool,
7040    csp_nonce: &str,
7041) -> Response {
7042    match fs::read(path) {
7043        Ok(bytes) => {
7044            let filename = build_pdf_filename(report_title, run_id);
7045            let disposition = if wants_download {
7046                format!("attachment; filename=\"{filename}\"")
7047            } else {
7048                format!("inline; filename=\"{filename}\"")
7049            };
7050            (
7051                [
7052                    (header::CONTENT_TYPE, "application/pdf".to_string()),
7053                    (header::CONTENT_DISPOSITION, disposition),
7054                ],
7055                bytes,
7056            )
7057                .into_response()
7058        }
7059        Err(err) => {
7060            let filename = path.file_name().map_or_else(
7061                || "report.pdf".to_string(),
7062                |n| n.to_string_lossy().into_owned(),
7063            );
7064            let msg = format!(
7065                "PDF report '{filename}' could not be read.\n\n\
7066                 Error: {err}\n\n\
7067                 If you moved or renamed the output folder, the stored path is now stale. \
7068                 Use 'Open PDF folder' from the results page to browse the output directory."
7069            );
7070            let html = ErrorTemplate {
7071                message: msg,
7072                last_report_url: Some("/view-reports".to_string()),
7073                last_report_label: Some("View Reports".to_string()),
7074                run_id: Some(run_id.to_owned()),
7075                error_code: Some(404),
7076                csp_nonce: csp_nonce.to_owned(),
7077                version: env!("CARGO_PKG_VERSION"),
7078            }
7079            .render()
7080            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7081            (StatusCode::NOT_FOUND, Html(html)).into_response()
7082        }
7083    }
7084}
7085
7086/// Serve the JSON artifact for a run — view or download.
7087fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7088    match fs::read(path) {
7089        Ok(bytes) => {
7090            if wants_download {
7091                (
7092                    [
7093                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7094                        (
7095                            header::CONTENT_DISPOSITION,
7096                            "attachment; filename=result.json",
7097                        ),
7098                    ],
7099                    bytes,
7100                )
7101                    .into_response()
7102            } else {
7103                (
7104                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7105                    bytes,
7106                )
7107                    .into_response()
7108            }
7109        }
7110        Err(err) => {
7111            let filename = path.file_name().map_or_else(
7112                || "result.json".to_string(),
7113                |n| n.to_string_lossy().into_owned(),
7114            );
7115            let msg = format!(
7116                "JSON result '{filename}' could not be read.\n\n\
7117                 Error: {err}\n\n\
7118                 If you moved or renamed the output folder, the stored path is now stale. \
7119                 Use 'Open JSON folder' from the results page to browse the output directory."
7120            );
7121            let html = ErrorTemplate {
7122                message: msg,
7123                last_report_url: Some("/view-reports".to_string()),
7124                last_report_label: Some("View Reports".to_string()),
7125                run_id: None,
7126                error_code: Some(404),
7127                csp_nonce: csp_nonce.to_owned(),
7128                version: env!("CARGO_PKG_VERSION"),
7129            }
7130            .render()
7131            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7132            (StatusCode::NOT_FOUND, Html(html)).into_response()
7133        }
7134    }
7135}
7136
7137/// Recover a `RunArtifacts` from the persisted registry for a run ID.
7138fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7139    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
7140    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
7141    let output_dir = entry
7142        .html_path
7143        .as_ref()
7144        .or(entry.json_path.as_ref())
7145        .or(entry.pdf_path.as_ref())
7146        .or(entry.csv_path.as_ref())
7147        .or(entry.xlsx_path.as_ref())
7148        .and_then(|p| {
7149            let parent = p.parent()?;
7150            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7151            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
7152            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7153                parent.parent().map(PathBuf::from)
7154            } else {
7155                Some(parent.to_path_buf())
7156            }
7157        })
7158        .unwrap_or_default();
7159    // Recover pdf_path: use the persisted one, or look for report.pdf
7160    // adjacent to html/json if only the old entries lack it.
7161    let pdf_path = entry.pdf_path.clone().or_else(|| {
7162        let candidate = output_dir.join("report.pdf");
7163        candidate.exists().then_some(candidate)
7164    });
7165    // csv_path / xlsx_path: persisted paths take precedence; fall back to
7166    // scanning the run directory for files matching the expected patterns so
7167    // that runs created before this feature still surface their artifacts.
7168    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7169        // Check excel/ subfolder (new layout) then root (old layout).
7170        for dir in &[output_dir.join("excel"), output_dir.clone()] {
7171            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7172                entries
7173                    .filter_map(std::result::Result::ok)
7174                    .find(|e| {
7175                        let n = e.file_name();
7176                        let n = n.to_string_lossy();
7177                        n.starts_with("report_") && n.ends_with(ext)
7178                    })
7179                    .map(|e| e.path())
7180            }) {
7181                return Some(p);
7182            }
7183        }
7184        None
7185    };
7186
7187    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7188    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7189    RunArtifacts {
7190        output_dir: output_dir.clone(),
7191        html_path: entry.html_path.clone(),
7192        pdf_path,
7193        json_path: entry.json_path.clone(),
7194        csv_path,
7195        xlsx_path,
7196        scan_config_path: find_scan_config_in_dir(&output_dir),
7197        report_title: entry.project_label.clone(),
7198        result_context: RunResultContext::default(),
7199    }
7200}
7201
7202#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
7203async fn resolve_artifact_set(
7204    state: &AppState,
7205    run_id: &str,
7206    csp_nonce: &str,
7207) -> Result<RunArtifacts, Response> {
7208    let cached = state.artifacts.lock().await.get(run_id).cloned();
7209    if let Some(a) = cached {
7210        return Ok(a);
7211    }
7212    let reg = state.registry.lock().await;
7213    if let Some(entry) = reg.find_by_run_id(run_id) {
7214        return Ok(recover_artifacts_from_registry(entry));
7215    }
7216    drop(reg);
7217    let short_id = &run_id[..run_id.len().min(8)];
7218    let hint = if matches!(
7219        run_id,
7220        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7221    ) {
7222        format!(
7223            " The URL format appears to be reversed \u{2014} \
7224             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7225             Use the View Reports page to navigate to your scan."
7226        )
7227    } else {
7228        " The report may have been deleted or the report directory moved. \
7229         Use View Reports to browse your scan history."
7230            .to_string()
7231    };
7232    let error_html = ErrorTemplate {
7233        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7234        last_report_url: Some("/view-reports".to_string()),
7235        last_report_label: Some("View Reports".to_string()),
7236        run_id: None,
7237        error_code: Some(404),
7238        csp_nonce: csp_nonce.to_owned(),
7239        version: env!("CARGO_PKG_VERSION"),
7240    }
7241    .render()
7242    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7243    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7244}
7245
7246/// Return the path to a run's PDF, queuing background generation when it is missing.
7247///
7248/// Returns `Ok(path)` when the PDF is known (it may still be generating).
7249/// Returns `Err(response)` when there is no JSON source to regenerate from.
7250async fn resolve_or_queue_pdf(
7251    state: &AppState,
7252    pdf_path: Option<PathBuf>,
7253    json_path: Option<PathBuf>,
7254    output_dir: PathBuf,
7255    run_id: &str,
7256    report_title: &str,
7257    csp_nonce: &str,
7258) -> Result<PathBuf, Response> {
7259    if let Some(p) = pdf_path {
7260        return Ok(p);
7261    }
7262    let Some(json_src) = json_path.filter(|p| p.exists()) else {
7263        let msg = "PDF report was not generated for this run. \
7264                   Re-run the analysis with PDF output enabled."
7265            .to_string();
7266        let html = ErrorTemplate {
7267            message: msg,
7268            last_report_url: Some(format!("/runs/html/{run_id}")),
7269            last_report_label: Some("View HTML Report".to_string()),
7270            run_id: Some(run_id.to_string()),
7271            error_code: Some(404),
7272            csp_nonce: csp_nonce.to_string(),
7273            version: env!("CARGO_PKG_VERSION"),
7274        }
7275        .render()
7276        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7277        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7278    };
7279    let pdf_filename = build_pdf_filename(report_title, run_id);
7280    let pdf_dest = output_dir.join(&pdf_filename);
7281    if !pdf_dest.exists() {
7282        // Record the pending path so concurrent requests show the spinner.
7283        {
7284            let mut map = state.artifacts.lock().await;
7285            if let Some(entry) = map.get_mut(run_id) {
7286                entry.pdf_path = Some(pdf_dest.clone());
7287            }
7288        }
7289        {
7290            let mut reg = state.registry.lock().await;
7291            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7292                e.pdf_path = Some(pdf_dest.clone());
7293            }
7294            let _ = reg.save(&state.registry_path);
7295        }
7296        spawn_native_pdf_background(
7297            json_src,
7298            pdf_dest.clone(),
7299            run_id.to_string(),
7300            state.artifacts.clone(),
7301        );
7302    }
7303    Ok(pdf_dest)
7304}
7305
7306/// Self-refreshing "please wait" page shown while the background PDF task is still running.
7307fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7308    let html = format!(
7309                    "<!doctype html><html lang=\"en\"><head>\
7310                     <meta charset=utf-8>\
7311                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7312                     <meta http-equiv=\"refresh\" content=\"5\">\
7313                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
7314                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7315                     <style nonce=\"{csp_nonce}\">\
7316                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7317                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7318                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7319                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7320                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7321                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7322                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7323                     background:var(--bg);color:var(--text);}}\
7324                     .top-nav{{position:sticky;top:0;z-index:30;\
7325                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7326                     border-bottom:1px solid rgba(255,255,255,0.12);\
7327                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7328                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7329                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
7330                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7331                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7332                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7333                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7334                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7335                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7336                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7337                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7338                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7339                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7340                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7341                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7342                     justify-content:center;min-height:38px;border-radius:999px;\
7343                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7344                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7345                     .theme-toggle .icon-sun{{display:none;}}\
7346                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7347                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7348                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7349                     display:flex;align-items:center;justify-content:center;\
7350                     min-height:calc(100vh - 56px);}}\
7351                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7352                     .panel{{background:var(--surface);border:1px solid var(--line);\
7353                     border-radius:var(--radius);box-shadow:var(--shadow);\
7354                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7355                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
7356                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
7357                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7358                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7359                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7360                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7361                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7362                     min-height:42px;padding:0 20px;border-radius:14px;\
7363                     border:1px solid var(--line-strong);text-decoration:none;\
7364                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7365                     .back-link:hover{{background:var(--line);}}\
7366                     </style></head>\
7367                     <body>\
7368                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7369                       <a class=\"brand\" href=\"/\">\
7370                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7371                         <div class=\"brand-copy\">\
7372                           <div class=\"brand-title\">OxideSLOC</div>\
7373                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7374                         </div>\
7375                       </a>\
7376                       <div class=\"nav-right\">\
7377                         <a class=\"nav-pill\" href=\"/\">Home</a>\
7378                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7379                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7380                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7381                           <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>\
7382                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7383                           <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>\
7384                         </button>\
7385                       </div>\
7386                     </div></div>\
7387                     <div class=\"page\"><div class=\"panel\">\
7388                       <div class=\"spin-ring\"></div>\
7389                       <h1>Generating PDF\u{2026}</h1>\
7390                       <p>The PDF is being generated from the scan results.<br>\
7391                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
7392                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7393                     </div></div>\
7394                     <script nonce=\"{csp_nonce}\">\
7395                     (function(){{\
7396                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7397                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
7398                       var t=document.getElementById(\"theme-toggle\");\
7399                       if(t)t.addEventListener(\"click\",function(){{\
7400                         var d=b.classList.toggle(\"dark-theme\");\
7401                         localStorage.setItem(k,d?\"dark\":\"light\");\
7402                       }});\
7403                     }})();\
7404                     </script>\
7405                     </body></html>"
7406    );
7407    Html(html).into_response()
7408}
7409
7410/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
7411fn render_error_artifact_html(
7412    message: String,
7413    last_report_url: Option<String>,
7414    last_report_label: Option<String>,
7415    run_id: Option<String>,
7416    error_code: Option<u16>,
7417    csp_nonce: &str,
7418) -> String {
7419    ErrorTemplate {
7420        message,
7421        last_report_url,
7422        last_report_label,
7423        run_id,
7424        error_code,
7425        csp_nonce: csp_nonce.to_owned(),
7426        version: env!("CARGO_PKG_VERSION"),
7427    }
7428    .render()
7429    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7430}
7431
7432/// Read a file and serve it as an attachment download.
7433fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7434    fs::read(path).map_or_else(
7435        |_| StatusCode::NOT_FOUND.into_response(),
7436        |bytes| {
7437            let filename = path.file_name().map_or_else(
7438                || fallback_filename.to_string(),
7439                |n| n.to_string_lossy().into_owned(),
7440            );
7441            (
7442                [
7443                    (header::CONTENT_TYPE, content_type.to_string()),
7444                    (
7445                        header::CONTENT_DISPOSITION,
7446                        format!("attachment; filename=\"{filename}\""),
7447                    ),
7448                ],
7449                bytes,
7450            )
7451                .into_response()
7452        },
7453    )
7454}
7455
7456fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7457    let Some(path) = csv_path else {
7458        let html = render_error_artifact_html(
7459            "CSV report was not generated for this run, or was not recorded in \
7460             the scan registry."
7461                .to_string(),
7462            Some(format!("/runs/html/{run_id}")),
7463            Some("View HTML Report".to_string()),
7464            Some(run_id.to_string()),
7465            Some(404),
7466            csp_nonce,
7467        );
7468        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7469    };
7470    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7471}
7472
7473fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7474    let Some(path) = xlsx_path else {
7475        let html = render_error_artifact_html(
7476            "Excel report was not generated for this run, or was not recorded in \
7477             the scan registry."
7478                .to_string(),
7479            Some(format!("/runs/html/{run_id}")),
7480            Some("View HTML Report".to_string()),
7481            Some(run_id.to_string()),
7482            Some(404),
7483            csp_nonce,
7484        );
7485        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7486    };
7487    serve_binary_download(
7488        &path,
7489        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7490        "report.xlsx",
7491    )
7492}
7493
7494fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7495    let path = artifact_set
7496        .scan_config_path
7497        .as_deref()
7498        .map(std::path::Path::to_path_buf)
7499        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7500        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7501    fs::read(&path).map_or_else(
7502        |_| StatusCode::NOT_FOUND.into_response(),
7503        |bytes| {
7504            (
7505                [
7506                    (
7507                        header::CONTENT_TYPE,
7508                        "application/json; charset=utf-8".to_string(),
7509                    ),
7510                    (
7511                        header::CONTENT_DISPOSITION,
7512                        "attachment; filename=\"scan-config.json\"".to_string(),
7513                    ),
7514                ],
7515                bytes,
7516            )
7517                .into_response()
7518        },
7519    )
7520}
7521
7522/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
7523/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
7524/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
7525/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
7526async fn serve_submodule_pdf_arm(
7527    artifact: &str,
7528    artifact_set: RunArtifacts,
7529    wants_download: bool,
7530    run_id: &str,
7531    csp_nonce: &str,
7532) -> Response {
7533    // "sub_benchmark_pdf" → base = "sub_benchmark"
7534    let base = artifact.trim_end_matches("_pdf");
7535    let sub_dir = artifact_set.output_dir.join("submodules");
7536    let pdf_path = sub_dir.join(format!("{base}.pdf"));
7537
7538    if !pdf_path.exists() {
7539        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
7540        let derived_safe = base.trim_start_matches("sub_");
7541        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7542            let parent_run = read_json(jp).ok()?;
7543            let sub = parent_run
7544                .submodule_summaries
7545                .iter()
7546                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7547                .clone();
7548            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7549            Some((parent_run, sub, parent_path))
7550        });
7551
7552        if let Some((parent_run, sub, parent_path)) = rebuilt {
7553            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7554            let pp = pdf_path.clone();
7555            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7556        }
7557    }
7558
7559    if !pdf_path.exists() {
7560        let html = render_error_artifact_html(
7561            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7562             enabled."
7563                .to_string(),
7564            Some("/view-reports".to_string()),
7565            Some("View Reports".to_string()),
7566            Some(run_id.to_string()),
7567            Some(404),
7568            csp_nonce,
7569        );
7570        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7571    }
7572
7573    serve_pdf_artifact(
7574        &pdf_path,
7575        &artifact_set.report_title,
7576        run_id,
7577        wants_download,
7578        csp_nonce,
7579    )
7580}
7581
7582fn serve_submodule_arm(
7583    artifact: &str,
7584    artifact_set: &RunArtifacts,
7585    wants_download: bool,
7586    csp_nonce: &str,
7587    run_id: &str,
7588    server_mode: bool,
7589) -> Response {
7590    if artifact.len() > 128
7591        || !artifact
7592            .chars()
7593            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7594    {
7595        return StatusCode::BAD_REQUEST.into_response();
7596    }
7597    let filename = format!("{artifact}.html");
7598    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
7599    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7600    let path = if new_layout.exists() {
7601        new_layout
7602    } else {
7603        artifact_set.output_dir.join(&filename)
7604    };
7605    if !path.exists() {
7606        let html = render_error_artifact_html(
7607            format!(
7608                "Sub-report '{artifact}' was not found in the run directory.\n\
7609                 Re-run the analysis with 'Detect and separate git submodules' \
7610                 and HTML output enabled."
7611            ),
7612            Some("/view-reports".to_string()),
7613            Some("View Reports".to_string()),
7614            Some(run_id.to_string()),
7615            Some(404),
7616            csp_nonce,
7617        );
7618        return (StatusCode::NOT_FOUND, Html(html)).into_response();
7619    }
7620    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7621}
7622
7623async fn serve_pdf_arm(
7624    state: &AppState,
7625    artifact_set: RunArtifacts,
7626    wants_download: bool,
7627    run_id: &str,
7628    csp_nonce: &str,
7629) -> Response {
7630    let report_title = artifact_set.report_title.clone();
7631    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7632    let stale_html_name = artifact_set
7633        .html_path
7634        .as_deref()
7635        .and_then(|p| p.file_name())
7636        .map(|n| n.to_string_lossy().into_owned());
7637    let path = match resolve_or_queue_pdf(
7638        state,
7639        artifact_set.pdf_path,
7640        artifact_set.json_path.clone(),
7641        artifact_set.output_dir.clone(),
7642        run_id,
7643        &report_title,
7644        csp_nonce,
7645    )
7646    .await
7647    {
7648        Ok(p) => p,
7649        Err(r) => return r,
7650    };
7651    if !path.exists() {
7652        // Distinguish a stale registry path (folder moved) from an in-progress
7653        // background generation. Only show the locate page when the PDF was
7654        // already recorded in the registry but the file is now missing.
7655        if had_pdf_in_registry {
7656            if let Some(expected_filename) = stale_html_name {
7657                let html = LocateFileTemplate {
7658                    run_id: run_id.to_string(),
7659                    artifact_type: "pdf".to_string(),
7660                    expected_filename,
7661                    server_mode: state.server_mode,
7662                    csp_nonce: csp_nonce.to_string(),
7663                    version: env!("CARGO_PKG_VERSION"),
7664                }
7665                .render()
7666                .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7667                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7668            }
7669        }
7670        return pdf_generating_response(run_id, csp_nonce);
7671    }
7672    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7673}
7674
7675async fn artifact_handler(
7676    State(state): State<AppState>,
7677    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7678    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7679    Query(query): Query<ArtifactQuery>,
7680) -> Response {
7681    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7682        Ok(a) => a,
7683        Err(r) => return r,
7684    };
7685
7686    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7687
7688    match artifact.as_str() {
7689        "html" => {
7690            let Some(path) = artifact_set.html_path else {
7691                return StatusCode::NOT_FOUND.into_response();
7692            };
7693            serve_html_artifact(
7694                &path,
7695                wants_download,
7696                &csp_nonce,
7697                &run_id,
7698                state.server_mode,
7699            )
7700        }
7701        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7702        "json" => {
7703            let Some(path) = artifact_set.json_path else {
7704                let html = render_error_artifact_html(
7705                    "JSON result was not generated for this run, or was not recorded in \
7706                     the scan registry. Re-run the analysis with JSON output enabled."
7707                        .to_string(),
7708                    Some("/view-reports".to_string()),
7709                    Some("View Reports".to_string()),
7710                    Some(run_id.clone()),
7711                    Some(404),
7712                    &csp_nonce,
7713                );
7714                return (StatusCode::NOT_FOUND, Html(html)).into_response();
7715            };
7716            serve_json_artifact(&path, wants_download, &csp_nonce)
7717        }
7718        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7719        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7720        "scan-config" => serve_scan_config_arm(&artifact_set),
7721        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7722            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7723                .await
7724        }
7725        _ if artifact.starts_with("sub_") => serve_submodule_arm(
7726            &artifact,
7727            &artifact_set,
7728            wants_download,
7729            &csp_nonce,
7730            &run_id,
7731            state.server_mode,
7732        ),
7733        _ => StatusCode::NOT_FOUND.into_response(),
7734    }
7735}
7736
7737// ── History ───────────────────────────────────────────────────────────────────
7738
7739struct SubmoduleLinkRow {
7740    name: String,
7741    url: String,
7742}
7743
7744struct HistoryEntryRow {
7745    run_id: String,
7746    run_id_short: String,
7747    timestamp: String,
7748    timestamp_utc_ms: i64,
7749    project_label: String,
7750    project_path: String,
7751    files_analyzed: u64,
7752    files_skipped: u64,
7753    code_lines: u64,
7754    comment_lines: u64,
7755    blank_lines: u64,
7756    total_physical_lines: u64,
7757    functions: u64,
7758    classes: u64,
7759    variables: u64,
7760    imports: u64,
7761    test_count: u64,
7762    git_branch: String,
7763    git_commit: String,
7764    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
7765    git_commit_long: String,
7766    has_html: bool,
7767    has_json: bool,
7768    has_pdf: bool,
7769    submodule_links: Vec<SubmoduleLinkRow>,
7770    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
7771    submodule_names_csv: String,
7772}
7773
7774/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
7775fn nth_weekday_of_month(
7776    year: i32,
7777    month: u32,
7778    weekday: chrono::Weekday,
7779    n: u32,
7780) -> chrono::NaiveDate {
7781    use chrono::Datelike;
7782    let mut count = 0u32;
7783    let mut day = 1u32;
7784    loop {
7785        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7786        if d.weekday() == weekday {
7787            count += 1;
7788            if count == n {
7789                return d;
7790            }
7791        }
7792        day += 1;
7793    }
7794}
7795
7796/// Returns true if `dt` falls within US Pacific Daylight Time.
7797/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
7798/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
7799fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7800    use chrono::{Datelike, TimeZone};
7801    let year = dt.year();
7802    let dst_start = chrono::Utc.from_utc_datetime(
7803        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7804            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7805    );
7806    let dst_end = chrono::Utc.from_utc_datetime(
7807        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7808            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7809    );
7810    dt >= dst_start && dt < dst_end
7811}
7812
7813fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7814    if is_pacific_dst(dt) {
7815        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7816            .format("%Y-%m-%d %H:%M PDT")
7817            .to_string()
7818    } else {
7819        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7820            .format("%Y-%m-%d %H:%M PST")
7821            .to_string()
7822    }
7823}
7824
7825/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
7826fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7827    let (offset, tz) = if is_pacific_dst(dt) {
7828        (
7829            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7830            "PDT",
7831        )
7832    } else {
7833        (
7834            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7835            "PST",
7836        )
7837    };
7838    format!(
7839        "{} {tz}",
7840        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7841    )
7842}
7843
7844fn fmt_git_date(iso: &str) -> Option<String> {
7845    chrono::DateTime::parse_from_rfc3339(iso)
7846        .ok()
7847        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7848}
7849
7850/// Recover the full-length commit SHA for a registry entry whose stored record
7851/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
7852///
7853/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
7854/// is serialized after the per-file records, near the end of the file. We read a
7855/// bounded tail and pick the `git_commit_long` value whose hash begins with the
7856/// known short SHA — this disambiguates the super-repo commit from any submodule
7857/// commits that also appear. Returns `None` if the file is unreadable or no match.
7858fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7859    use std::io::{Read, Seek, SeekFrom};
7860    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
7861    if short.is_empty() {
7862        return None;
7863    }
7864    let len = std::fs::metadata(path).ok()?.len();
7865    let start = len.saturating_sub(TAIL);
7866    let mut file = std::fs::File::open(path).ok()?;
7867    file.seek(SeekFrom::Start(start)).ok()?;
7868    let mut buf = Vec::new();
7869    file.read_to_end(&mut buf).ok()?;
7870    let text = String::from_utf8_lossy(&buf);
7871    let short_lower = short.to_ascii_lowercase();
7872    let key = "\"git_commit_long\"";
7873    let mut found: Option<String> = None;
7874    let mut cursor = 0usize;
7875    while let Some(idx) = text[cursor..].find(key) {
7876        let after_key = cursor + idx + key.len();
7877        cursor = after_key;
7878        let rest = &text[after_key..];
7879        let Some(colon) = rest.find(':') else { break };
7880        let value_region = rest[colon + 1..].trim_start();
7881        // Skip `null` (or any non-string) values without consuming the next field.
7882        if let Some(open) = value_region.strip_prefix('"') {
7883            if let Some(close) = open.find('"') {
7884                let val = &open[..close];
7885                if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7886                    found = Some(val.to_string());
7887                }
7888            }
7889        }
7890    }
7891    found
7892}
7893
7894fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7895    reg.entries
7896        .iter()
7897        .map(|e| {
7898            let submodule_links = {
7899                let mut links: Vec<SubmoduleLinkRow> = vec![];
7900                let sub_dir = e
7901                    .html_path
7902                    .as_ref()
7903                    .and_then(|p| p.parent())
7904                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7905                if let Some(dir) = sub_dir {
7906                    if let Ok(rd) = std::fs::read_dir(dir) {
7907                        for entry_res in rd.flatten() {
7908                            let fname = entry_res.file_name();
7909                            let fname_str = fname.to_string_lossy();
7910                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7911                                let stem = &fname_str[..fname_str.len() - 5];
7912                                let display = stem[4..].replace('-', " ");
7913                                links.push(SubmoduleLinkRow {
7914                                    name: display,
7915                                    url: format!("/runs/{stem}/{}", e.run_id),
7916                                });
7917                            }
7918                        }
7919                    }
7920                }
7921                links.sort_by(|a, b| a.name.cmp(&b.name));
7922                links
7923            };
7924            let submodule_names_csv = submodule_links
7925                .iter()
7926                .map(|l| l.name.as_str())
7927                .collect::<Vec<_>>()
7928                .join(",");
7929            HistoryEntryRow {
7930                run_id: e.run_id.clone(),
7931                run_id_short: e
7932                    .run_id
7933                    .split('-')
7934                    .next_back()
7935                    .unwrap_or(&e.run_id)
7936                    .chars()
7937                    .take(7)
7938                    .collect(),
7939                timestamp: fmt_la_time(e.timestamp_utc),
7940                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7941                project_label: e.project_label.clone(),
7942                project_path: e
7943                    .input_roots
7944                    .first()
7945                    .map(|s| sanitize_path_str(s))
7946                    .unwrap_or_default(),
7947                files_analyzed: e.summary.files_analyzed,
7948                files_skipped: e.summary.files_skipped,
7949                code_lines: e.summary.code_lines,
7950                comment_lines: e.summary.comment_lines,
7951                blank_lines: e.summary.blank_lines,
7952                total_physical_lines: e.summary.total_physical_lines,
7953                functions: e.summary.functions,
7954                classes: e.summary.classes,
7955                variables: e.summary.variables,
7956                imports: e.summary.imports,
7957                test_count: e.summary.test_count,
7958                git_branch: e.git_branch.clone().unwrap_or_default(),
7959                git_commit: e.git_commit.clone().unwrap_or_default(),
7960                git_commit_long: {
7961                    let short = e.git_commit.clone().unwrap_or_default();
7962                    e.git_commit_long
7963                        .clone()
7964                        .filter(|s| !s.is_empty())
7965                        .or_else(|| {
7966                            e.json_path
7967                                .as_ref()
7968                                .and_then(|p| extract_long_commit_from_json(p, &short))
7969                        })
7970                        .unwrap_or(short)
7971                },
7972                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7973                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7974                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7975                submodule_links,
7976                submodule_names_csv,
7977            }
7978        })
7979        .collect()
7980}
7981
7982#[derive(Deserialize, Default)]
7983struct HistoryQuery {
7984    linked: Option<String>,
7985    error: Option<String>,
7986}
7987
7988async fn history_handler(
7989    State(state): State<AppState>,
7990    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7991    Query(query): Query<HistoryQuery>,
7992) -> impl IntoResponse {
7993    // Auto-scan all watched directories before rendering so the list stays fresh.
7994    auto_scan_watched_dirs(&state).await;
7995    let watched_dirs: Vec<String> = {
7996        let wd = state.watched_dirs.lock().await;
7997        wd.dirs.iter().map(|p| p.display().to_string()).collect()
7998    };
7999    let mut entries = {
8000        let reg = state.registry.lock().await;
8001        make_history_rows(&reg)
8002    };
8003    entries.retain(|e| e.has_html);
8004    let total_scans = entries.len();
8005    let linked_count = query
8006        .linked
8007        .as_deref()
8008        .and_then(|s| s.parse::<usize>().ok())
8009        .unwrap_or(0);
8010    let browse_error = query.error.filter(|s| !s.is_empty());
8011    let template = HistoryTemplate {
8012        version: env!("CARGO_PKG_VERSION"),
8013        entries,
8014        total_scans,
8015        linked_count,
8016        browse_error,
8017        watched_dirs,
8018        csp_nonce,
8019        server_mode: state.server_mode,
8020    };
8021    Html(
8022        template
8023            .render()
8024            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8025    )
8026    .into_response()
8027}
8028
8029async fn compare_select_handler(
8030    State(state): State<AppState>,
8031    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8032) -> impl IntoResponse {
8033    auto_scan_watched_dirs(&state).await;
8034    let watched_dirs: Vec<String> = {
8035        let wd = state.watched_dirs.lock().await;
8036        wd.dirs.iter().map(|p| p.display().to_string()).collect()
8037    };
8038    let mut entries = {
8039        let reg = state.registry.lock().await;
8040        make_history_rows(&reg)
8041    };
8042    entries.retain(|e| e.has_json);
8043    let total_scans = entries.len();
8044    let template = CompareSelectTemplate {
8045        version: env!("CARGO_PKG_VERSION"),
8046        entries,
8047        total_scans,
8048        watched_dirs,
8049        csp_nonce,
8050        server_mode: state.server_mode,
8051    };
8052    Html(
8053        template
8054            .render()
8055            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8056    )
8057    .into_response()
8058}
8059
8060// ── Compare ───────────────────────────────────────────────────────────────────
8061
8062#[derive(Deserialize, Default)]
8063struct CompareQuery {
8064    a: Option<String>,
8065    b: Option<String>,
8066    /// Optional submodule name to scope the comparison to one submodule.
8067    sub: Option<String>,
8068    /// "super" to exclude all submodule files and show only the super-repo.
8069    scope: Option<String>,
8070}
8071
8072struct CompareFileDeltaRow {
8073    relative_path: String,
8074    language: String,
8075    status: String,
8076    baseline_code: i64,
8077    current_code: i64,
8078    baseline_code_display: String,
8079    current_code_display: String,
8080    code_delta_str: String,
8081    code_delta_class: String,
8082    comment_delta_str: String,
8083    comment_delta_class: String,
8084    total_delta_str: String,
8085    total_delta_class: String,
8086}
8087
8088/// Recompute `summary_totals` from the current `per_file_records` slice.
8089/// Used when `per_file_records` has been narrowed to a submodule subset.
8090fn recompute_summary_from_records(run: &mut AnalysisRun) {
8091    let mut totals = SummaryTotals::default();
8092    for r in &run.per_file_records {
8093        if r.language.is_some() {
8094            totals.files_analyzed += 1;
8095        }
8096        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8097        totals.code_lines += r.effective_counts.code_lines;
8098        totals.comment_lines += r.effective_counts.comment_lines;
8099        totals.blank_lines += r.effective_counts.blank_lines;
8100        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8101        totals.functions += r.raw_line_categories.functions;
8102        totals.classes += r.raw_line_categories.classes;
8103        totals.variables += r.raw_line_categories.variables;
8104        totals.imports += r.raw_line_categories.imports;
8105        totals.test_count += r.raw_line_categories.test_count;
8106        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8107        totals.test_suite_count += r.raw_line_categories.test_suite_count;
8108        if let Some(cov) = &r.coverage {
8109            totals.coverage_lines_found += u64::from(cov.lines_found);
8110            totals.coverage_lines_hit += u64::from(cov.lines_hit);
8111            totals.coverage_functions_found += u64::from(cov.functions_found);
8112            totals.coverage_functions_hit += u64::from(cov.functions_hit);
8113            totals.coverage_branches_found += u64::from(cov.branches_found);
8114            totals.coverage_branches_hit += u64::from(cov.branches_hit);
8115        }
8116    }
8117    totals.files_considered = totals.files_analyzed;
8118    run.summary_totals = totals;
8119}
8120
8121fn fmt_delta(n: i64) -> String {
8122    if n > 0 {
8123        format!("+{n}")
8124    } else {
8125        format!("{n}")
8126    }
8127}
8128
8129fn delta_class(n: i64) -> &'static str {
8130    use std::cmp::Ordering;
8131    match n.cmp(&0) {
8132        Ordering::Greater => "pos",
8133        Ordering::Less => "neg",
8134        Ordering::Equal => "zero",
8135    }
8136}
8137
8138// ratio/percentage display, precision loss acceptable
8139#[allow(clippy::cast_precision_loss)]
8140fn fmt_pct(delta: i64, baseline: u64) -> String {
8141    if baseline == 0 {
8142        return "—".to_string();
8143    }
8144    #[allow(clippy::cast_precision_loss)]
8145    let pct = (delta as f64 / baseline as f64) * 100.0;
8146    if pct > 0.049 {
8147        format!("+{pct:.1}%")
8148    } else if pct < -0.049 {
8149        format!("{pct:.1}%")
8150    } else {
8151        "±0%".to_string()
8152    }
8153}
8154
8155/// Returns (`display_string`, `css_class`) for a numeric change column cell.
8156fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8157    prev.map_or_else(
8158        || ("—".to_string(), "na"),
8159        |p| {
8160            #[allow(clippy::cast_possible_wrap)]
8161            let d = curr as i64 - p as i64;
8162            (fmt_delta(d), delta_class(d))
8163        },
8164    )
8165}
8166
8167#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
8168fn load_scan_for_compare(
8169    json_path: &std::path::Path,
8170    scan_label: &str,
8171    run_id: &str,
8172    server_mode: bool,
8173    compare_url: &str,
8174    csp_nonce: &str,
8175) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8176    match read_json(json_path) {
8177        Ok(r) => Ok(r),
8178        Err(e) => {
8179            if server_mode {
8180                let html = ErrorTemplate {
8181                    message: format!(
8182                        "Could not load {scan_label} scan data. The scan output folder may have \
8183                         been moved, renamed, or deleted. Re-running the analysis will create \
8184                         fresh comparison data."
8185                    ),
8186                    last_report_url: Some("/compare-scans".to_string()),
8187                    last_report_label: Some("Compare Scans".to_string()),
8188                    run_id: Some(run_id.to_owned()),
8189                    error_code: Some(404),
8190                    csp_nonce: csp_nonce.to_owned(),
8191                    version: env!("CARGO_PKG_VERSION"),
8192                }
8193                .render()
8194                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8195                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8196            }
8197            let msg = format!(
8198                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8199                json_path.display()
8200            );
8201            let folder_hint = output_folder_hint(json_path);
8202            Err(missing_scan_relocate_response(
8203                &msg,
8204                run_id,
8205                &folder_hint,
8206                compare_url,
8207                false,
8208                csp_nonce,
8209            ))
8210        }
8211    }
8212}
8213
8214struct ChurnStats {
8215    new_scope: bool,
8216    scope_flag: bool,
8217    churn_rate_str: String,
8218    churn_rate_class: String,
8219}
8220
8221fn compute_churn_stats(
8222    baseline_code: u64,
8223    current_code: u64,
8224    lines_added: i64,
8225    lines_removed: i64,
8226) -> ChurnStats {
8227    let new_scope = baseline_code == 0 && current_code > 0;
8228    #[allow(clippy::cast_precision_loss)]
8229    let churn_pct = if baseline_code > 0 {
8230        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8231    } else {
8232        0.0
8233    };
8234    #[allow(clippy::cast_precision_loss)]
8235    let scope_flag =
8236        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8237    let churn_rate_str = if new_scope {
8238        "New".to_string()
8239    } else if baseline_code > 0 {
8240        format!("{churn_pct:.1}%")
8241    } else {
8242        "—".to_string()
8243    };
8244    let churn_rate_class = if new_scope || churn_pct > 20.0 {
8245        "high".to_string()
8246    } else if churn_pct > 5.0 {
8247        "med".to_string()
8248    } else {
8249        "low".to_string()
8250    };
8251    ChurnStats {
8252        new_scope,
8253        scope_flag,
8254        churn_rate_str,
8255        churn_rate_class,
8256    }
8257}
8258
8259/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
8260/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
8261/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
8262fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8263    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8264    if !has_data {
8265        return String::new();
8266    }
8267    let base_str = s
8268        .baseline_coverage_line_pct
8269        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8270    let curr_str = s
8271        .current_coverage_line_pct
8272        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8273    let (delta_str, cls) = match s.coverage_line_pct_delta {
8274        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8275        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8276        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8277        None => ("\u{2014}".into(), "zero"),
8278    };
8279    format!(
8280        r#"<div class="delta-card">
8281          <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>
8282          <div class="delta-card-label">Line coverage</div>
8283          <div class="delta-card-from">Before: {base_str}</div>
8284          <div class="delta-card-to">{curr_str}</div>
8285          <span class="delta-card-change {cls}">{delta_str}</span>
8286        </div>"#
8287    )
8288}
8289
8290/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
8291#[allow(clippy::ref_option)]
8292fn narrow_run_pair_by_scope(
8293    mut baseline: AnalysisRun,
8294    mut current: AnalysisRun,
8295    active_sub: &Option<String>,
8296    super_scope: bool,
8297) -> (AnalysisRun, AnalysisRun) {
8298    if let Some(ref sub_name) = active_sub {
8299        baseline
8300            .per_file_records
8301            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8302        current
8303            .per_file_records
8304            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8305        recompute_summary_from_records(&mut baseline);
8306        recompute_summary_from_records(&mut current);
8307    } else if super_scope {
8308        baseline.per_file_records.retain(|f| f.submodule.is_none());
8309        current.per_file_records.retain(|f| f.submodule.is_none());
8310        recompute_summary_from_records(&mut baseline);
8311        recompute_summary_from_records(&mut current);
8312    }
8313    (baseline, current)
8314}
8315
8316/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
8317#[allow(clippy::ref_option)]
8318fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8319    if let Some(ref sub_name) = active_sub {
8320        for run in runs.iter_mut() {
8321            run.per_file_records
8322                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8323            recompute_summary_from_records(run);
8324        }
8325    } else if super_scope {
8326        for run in runs.iter_mut() {
8327            run.per_file_records.retain(|f| f.submodule.is_none());
8328            recompute_summary_from_records(run);
8329        }
8330    }
8331}
8332
8333#[allow(clippy::too_many_lines)]
8334async fn compare_handler(
8335    State(state): State<AppState>,
8336    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8337    Query(query): Query<CompareQuery>,
8338) -> impl IntoResponse {
8339    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
8340    // redirect to the history page where the user can select two runs.
8341    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8342        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8343        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8344    };
8345
8346    let (maybe_a, maybe_b) = {
8347        let reg = state.registry.lock().await;
8348        (
8349            reg.find_by_run_id(&run_id_a).cloned(),
8350            reg.find_by_run_id(&run_id_b).cloned(),
8351        )
8352    };
8353
8354    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8355        let html = ErrorTemplate {
8356            message: "One or both run IDs were not found in scan history. \
8357                      The runs may have been deleted or the registry may have been reset."
8358                .to_string(),
8359            last_report_url: Some("/compare-scans".to_string()),
8360            last_report_label: Some("Compare Scans".to_string()),
8361            run_id: None,
8362            error_code: None,
8363            csp_nonce: csp_nonce.clone(),
8364            version: env!("CARGO_PKG_VERSION"),
8365        }
8366        .render()
8367        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8368        return Html(html).into_response();
8369    };
8370
8371    // Ensure older scan is always the baseline.
8372    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8373        (entry_a, entry_b)
8374    } else {
8375        (entry_b, entry_a)
8376    };
8377
8378    // If query params were in the wrong order, redirect to canonical URL so the
8379    // browser always shows the same URL for the same two scans regardless of how
8380    // the user arrived here (Full diff button vs. Compare Scans selection).
8381    if baseline_entry.run_id != run_id_a {
8382        let canonical = format!(
8383            "/compare?a={}&b={}",
8384            baseline_entry.run_id, current_entry.run_id
8385        );
8386        return axum::response::Redirect::to(&canonical).into_response();
8387    }
8388
8389    let (Some(base_json), Some(curr_json)) = (
8390        baseline_entry.json_path.as_ref(),
8391        current_entry.json_path.as_ref(),
8392    ) else {
8393        let html = ErrorTemplate {
8394            message: "Full comparison requires JSON scan data, which was not saved for one or \
8395                      both of these runs. JSON is now always saved for new scans — re-run the \
8396                      affected projects to enable comparisons."
8397                .to_string(),
8398            last_report_url: Some("/compare-scans".to_string()),
8399            last_report_label: Some("Compare Scans".to_string()),
8400            run_id: None,
8401            error_code: None,
8402            csp_nonce: csp_nonce.clone(),
8403            version: env!("CARGO_PKG_VERSION"),
8404        }
8405        .render()
8406        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8407        return Html(html).into_response();
8408    };
8409
8410    let compare_url = format!(
8411        "/compare?a={}&b={}",
8412        baseline_entry.run_id, current_entry.run_id
8413    );
8414
8415    let baseline_run = match load_scan_for_compare(
8416        base_json,
8417        "baseline",
8418        &baseline_entry.run_id,
8419        state.server_mode,
8420        &compare_url,
8421        &csp_nonce,
8422    ) {
8423        Ok(r) => r,
8424        Err(resp) => return resp,
8425    };
8426    let current_run = match load_scan_for_compare(
8427        curr_json,
8428        "current",
8429        &current_entry.run_id,
8430        state.server_mode,
8431        &compare_url,
8432        &csp_nonce,
8433    ) {
8434        Ok(r) => r,
8435        Err(resp) => return resp,
8436    };
8437
8438    let active_submodule = query.sub.clone();
8439    let super_scope_active = query.scope.as_deref() == Some("super");
8440
8441    let submodule_options = baseline_run
8442        .submodule_summaries
8443        .iter()
8444        .chain(current_run.submodule_summaries.iter())
8445        .map(|s| s.name.clone())
8446        .collect::<std::collections::BTreeSet<_>>()
8447        .into_iter()
8448        .collect::<Vec<_>>();
8449    let has_any_submodule_data = !submodule_options.is_empty();
8450
8451    // Narrow per_file_records when a scope is active, then recompute totals.
8452    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8453        baseline_run,
8454        current_run,
8455        &active_submodule,
8456        super_scope_active,
8457    );
8458
8459    let comparison = compute_delta(&effective_baseline, &effective_current);
8460
8461    let file_rows: Vec<CompareFileDeltaRow> = comparison
8462        .file_deltas
8463        .iter()
8464        .map(|d| CompareFileDeltaRow {
8465            relative_path: d.relative_path.clone(),
8466            language: d.language.clone().unwrap_or_else(|| "—".into()),
8467            status: match d.status {
8468                FileChangeStatus::Added => "added".into(),
8469                FileChangeStatus::Removed => "removed".into(),
8470                FileChangeStatus::Modified => "modified".into(),
8471                FileChangeStatus::Unchanged => "unchanged".into(),
8472            },
8473            baseline_code: d.baseline_code,
8474            current_code: d.current_code,
8475            baseline_code_display: if d.status == FileChangeStatus::Added {
8476                "—".into()
8477            } else {
8478                d.baseline_code.to_string()
8479            },
8480            current_code_display: if d.status == FileChangeStatus::Removed {
8481                "—".into()
8482            } else {
8483                d.current_code.to_string()
8484            },
8485            code_delta_str: fmt_delta(d.code_delta),
8486            code_delta_class: delta_class(d.code_delta).into(),
8487            comment_delta_str: fmt_delta(d.comment_delta),
8488            comment_delta_class: delta_class(d.comment_delta).into(),
8489            total_delta_str: fmt_delta(d.total_delta),
8490            total_delta_class: delta_class(d.total_delta).into(),
8491        })
8492        .collect();
8493
8494    let project_path = baseline_entry
8495        .input_roots
8496        .first()
8497        .map(|s| sanitize_path_str(s))
8498        .unwrap_or_default();
8499    let lines_added = sum_added_code_lines(&comparison);
8500    let lines_removed = sum_removed_code_lines(&comparison);
8501    let churn = compute_churn_stats(
8502        comparison.summary.baseline_code,
8503        comparison.summary.current_code,
8504        lines_added,
8505        lines_removed,
8506    );
8507    let s = &comparison.summary;
8508    let template = CompareTemplate {
8509        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8510        version: env!("CARGO_PKG_VERSION"),
8511        project_label: baseline_entry.project_label.clone(),
8512        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8513        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8514        baseline_run_id: baseline_entry.run_id.clone(),
8515        current_run_id: current_entry.run_id.clone(),
8516        baseline_run_id_short: baseline_entry
8517            .run_id
8518            .split('-')
8519            .next_back()
8520            .unwrap_or(&baseline_entry.run_id)
8521            .chars()
8522            .take(7)
8523            .collect(),
8524        current_run_id_short: current_entry
8525            .run_id
8526            .split('-')
8527            .next_back()
8528            .unwrap_or(&current_entry.run_id)
8529            .chars()
8530            .take(7)
8531            .collect(),
8532        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8533        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8534        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8535        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8536        project_path: project_path.clone(),
8537        baseline_code: s.baseline_code,
8538        current_code: s.current_code,
8539        code_lines_delta_str: fmt_delta(s.code_lines_delta),
8540        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8541        baseline_files: s.baseline_files,
8542        current_files: s.current_files,
8543        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8544        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8545        baseline_comments: s.baseline_comments,
8546        current_comments: s.current_comments,
8547        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8548        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8549        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8550        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8551        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8552        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8553        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8554        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8555        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8556        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8557        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8558        code_lines_added: lines_added,
8559        code_lines_removed: lines_removed,
8560        code_lines_modified: sum_modified_code_lines(&comparison),
8561        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8562        new_scope: churn.new_scope,
8563        churn_rate_str: churn.churn_rate_str,
8564        churn_rate_class: churn.churn_rate_class,
8565        scope_flag: churn.scope_flag,
8566        files_added: comparison.files_added,
8567        files_removed: comparison.files_removed,
8568        files_modified: comparison.files_modified,
8569        files_unchanged: comparison.files_unchanged,
8570        file_rows,
8571        baseline_git_author: baseline_entry.git_author.clone(),
8572        current_git_author: current_entry.git_author.clone(),
8573        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8574        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8575        baseline_git_tags: baseline_entry.git_tags.clone(),
8576        current_git_tags: current_entry.git_tags.clone(),
8577        baseline_git_commit_date: baseline_entry
8578            .git_commit_date
8579            .as_deref()
8580            .and_then(fmt_git_date),
8581        current_git_commit_date: current_entry
8582            .git_commit_date
8583            .as_deref()
8584            .and_then(fmt_git_date),
8585        project_name: project_path
8586            .rsplit(['/', '\\'])
8587            .find(|s| !s.is_empty())
8588            .unwrap_or(&project_path)
8589            .to_string(),
8590        submodule_options,
8591        has_any_submodule_data,
8592        active_submodule,
8593        super_scope_active,
8594        toast_assets: sloc_toast_assets(&csp_nonce),
8595        csp_nonce,
8596        coverage_delta_card: build_coverage_delta_card(s),
8597        baseline_test_count: effective_baseline.summary_totals.test_count,
8598        current_test_count: effective_current.summary_totals.test_count,
8599        baseline_coverage_pct: s.baseline_coverage_line_pct,
8600        current_coverage_pct: s.current_coverage_line_pct,
8601    };
8602
8603    Html(
8604        template
8605            .render()
8606            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8607    )
8608    .into_response()
8609}
8610
8611// ── Badge endpoint ────────────────────────────────────────────────────────────
8612// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
8613// pages, Jira descriptions, etc.
8614//
8615// GET /badge/<metric>?label=<override>&color=<hex>
8616// Metrics: code-lines  files  comment-lines  blank-lines
8617
8618fn format_number(n: u64) -> String {
8619    let s = n.to_string();
8620    let mut out = String::with_capacity(s.len() + s.len() / 3);
8621    let len = s.len();
8622    for (i, c) in s.chars().enumerate() {
8623        if i > 0 && (len - i).is_multiple_of(3) {
8624            out.push(',');
8625        }
8626        out.push(c);
8627    }
8628    out
8629}
8630
8631const fn badge_char_width(c: char) -> f64 {
8632    match c {
8633        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8634        'm' | 'w' => 9.0,
8635        ' ' => 4.0,
8636        _ => 6.5,
8637    }
8638}
8639
8640#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8641fn badge_text_px(text: &str) -> u32 {
8642    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8643}
8644
8645fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8646    let lw = badge_text_px(label) + 20;
8647    let rw = badge_text_px(value) + 20;
8648    let total = lw + rw;
8649    let lx = lw / 2;
8650    let rx = lw + rw / 2;
8651    let le = escape_html(label);
8652    let ve = escape_html(value);
8653    let ce = escape_html(color);
8654    format!(
8655        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8656  <rect width="{total}" height="20" fill="#555"/>
8657  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8658  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8659    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8660    <text x="{lx}" y="13">{le}</text>
8661    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8662    <text x="{rx}" y="13">{ve}</text>
8663  </g>
8664</svg>"##
8665    )
8666}
8667
8668#[derive(Deserialize)]
8669struct BadgeQuery {
8670    label: Option<String>,
8671    color: Option<String>,
8672}
8673
8674async fn badge_handler(
8675    State(state): State<AppState>,
8676    AxumPath(metric): AxumPath<String>,
8677    Query(query): Query<BadgeQuery>,
8678) -> Response {
8679    let entry = {
8680        let reg = state.registry.lock().await;
8681        reg.entries.first().cloned()
8682    };
8683
8684    let Some(entry) = entry else {
8685        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8686        return (
8687            [
8688                (header::CONTENT_TYPE, "image/svg+xml"),
8689                (header::CACHE_CONTROL, "no-cache, max-age=0"),
8690            ],
8691            svg,
8692        )
8693            .into_response();
8694    };
8695
8696    let (default_label, value, default_color) = match metric.as_str() {
8697        "code-lines" => (
8698            "code lines",
8699            format_number(entry.summary.code_lines),
8700            "#4a78ee",
8701        ),
8702        "files" => (
8703            "files analyzed",
8704            format_number(entry.summary.files_analyzed),
8705            "#4a9862",
8706        ),
8707        "comment-lines" => (
8708            "comment lines",
8709            format_number(entry.summary.comment_lines),
8710            "#b35428",
8711        ),
8712        "blank-lines" => (
8713            "blank lines",
8714            format_number(entry.summary.blank_lines),
8715            "#7a5db0",
8716        ),
8717        _ => return StatusCode::NOT_FOUND.into_response(),
8718    };
8719
8720    let label = query.label.as_deref().unwrap_or(default_label);
8721    let color = query.color.as_deref().unwrap_or(default_color);
8722    let svg = render_badge_svg(label, &value, color);
8723
8724    (
8725        [
8726            (header::CONTENT_TYPE, "image/svg+xml"),
8727            (header::CACHE_CONTROL, "no-cache, max-age=0"),
8728        ],
8729        svg,
8730    )
8731        .into_response()
8732}
8733
8734// ── Metrics API ───────────────────────────────────────────────────────────────
8735// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
8736// Confluence automation, Jira webhooks, etc.
8737//
8738// GET /api/metrics/latest
8739// GET /api/metrics/<run_id>
8740
8741#[derive(Serialize)]
8742struct ApiCoverageBlock {
8743    lines_found: u64,
8744    lines_hit: u64,
8745    line_pct: f64,
8746    functions_found: u64,
8747    functions_hit: u64,
8748    function_pct: f64,
8749    branches_found: u64,
8750    branches_hit: u64,
8751    branch_pct: f64,
8752}
8753
8754#[derive(Serialize)]
8755struct ApiMetricsResponse {
8756    run_id: String,
8757    timestamp: String,
8758    project: String,
8759    summary: ApiSummaryPayload,
8760    languages: Vec<ApiLanguageRow>,
8761    #[serde(skip_serializing_if = "Option::is_none")]
8762    coverage: Option<ApiCoverageBlock>,
8763}
8764
8765#[derive(Serialize)]
8766struct ApiSummaryPayload {
8767    files_analyzed: u64,
8768    files_skipped: u64,
8769    code_lines: u64,
8770    comment_lines: u64,
8771    blank_lines: u64,
8772    total_physical_lines: u64,
8773    functions: u64,
8774    classes: u64,
8775    variables: u64,
8776    imports: u64,
8777}
8778
8779#[derive(Serialize)]
8780struct ApiLanguageRow {
8781    name: String,
8782    files: u64,
8783    code_lines: u64,
8784    comment_lines: u64,
8785    blank_lines: u64,
8786    functions: u64,
8787    classes: u64,
8788    variables: u64,
8789    imports: u64,
8790}
8791
8792async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8793    let entry = {
8794        let reg = state.registry.lock().await;
8795        reg.entries.first().cloned()
8796    };
8797    entry.map_or_else(
8798        || error::not_found("no scans recorded yet"),
8799        |e| build_metrics_response(&e),
8800    )
8801}
8802
8803async fn api_metrics_run_handler(
8804    State(state): State<AppState>,
8805    AxumPath(run_id): AxumPath<String>,
8806) -> Response {
8807    let entry = {
8808        let reg = state.registry.lock().await;
8809        reg.find_by_run_id(&run_id).cloned()
8810    };
8811    entry.map_or_else(
8812        || error::not_found("run not found"),
8813        |e| build_metrics_response(&e),
8814    )
8815}
8816
8817fn build_metrics_response(entry: &RegistryEntry) -> Response {
8818    let languages: Vec<ApiLanguageRow> = entry
8819        .json_path
8820        .as_ref()
8821        .and_then(|p| read_json(p).ok())
8822        .map(|run| {
8823            run.totals_by_language
8824                .iter()
8825                .map(|l| ApiLanguageRow {
8826                    name: l.language.display_name().to_string(),
8827                    files: l.files,
8828                    code_lines: l.code_lines,
8829                    comment_lines: l.comment_lines,
8830                    blank_lines: l.blank_lines,
8831                    functions: l.functions,
8832                    classes: l.classes,
8833                    variables: l.variables,
8834                    imports: l.imports,
8835                })
8836                .collect()
8837        })
8838        .unwrap_or_default();
8839
8840    let s = &entry.summary;
8841    let coverage = if s.coverage_lines_found > 0 {
8842        let pct = |hit: u64, found: u64| -> f64 {
8843            if found == 0 {
8844                0.0
8845            } else {
8846                #[allow(clippy::cast_precision_loss)]
8847                let v = (hit as f64 / found as f64) * 100.0;
8848                (v * 10.0).round() / 10.0
8849            }
8850        };
8851        Some(ApiCoverageBlock {
8852            lines_found: s.coverage_lines_found,
8853            lines_hit: s.coverage_lines_hit,
8854            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8855            functions_found: s.coverage_functions_found,
8856            functions_hit: s.coverage_functions_hit,
8857            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8858            branches_found: s.coverage_branches_found,
8859            branches_hit: s.coverage_branches_hit,
8860            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8861        })
8862    } else {
8863        None
8864    };
8865    Json(ApiMetricsResponse {
8866        run_id: entry.run_id.clone(),
8867        timestamp: entry.timestamp_utc.to_rfc3339(),
8868        project: entry.project_label.clone(),
8869        summary: ApiSummaryPayload {
8870            files_analyzed: s.files_analyzed,
8871            files_skipped: s.files_skipped,
8872            code_lines: s.code_lines,
8873            comment_lines: s.comment_lines,
8874            blank_lines: s.blank_lines,
8875            total_physical_lines: s.total_physical_lines,
8876            functions: s.functions,
8877            classes: s.classes,
8878            variables: s.variables,
8879            imports: s.imports,
8880        },
8881        languages,
8882        coverage,
8883    })
8884    .into_response()
8885}
8886
8887// ── Project history API ───────────────────────────────────────────────────────
8888// Protected. Called by the wizard JS when the project path changes, so the UI
8889// can show a "scanned N times before" badge without a full page reload.
8890//
8891// GET /api/project-history?path=<project_root>
8892
8893#[derive(Deserialize)]
8894struct ProjectHistoryQuery {
8895    path: Option<String>,
8896}
8897
8898#[derive(Serialize)]
8899struct ProjectHistoryResponse {
8900    scan_count: usize,
8901    last_scan_id: Option<String>,
8902    last_scan_timestamp: Option<String>,
8903    last_scan_code_lines: Option<u64>,
8904    last_git_branch: Option<String>,
8905    last_git_commit: Option<String>,
8906}
8907
8908/// Return true if `entry` matches either an exact root path or an upload-staging
8909/// path with the same project name (needed because each upload gets a fresh UUID dir).
8910fn entry_matches_project(
8911    entry: &RegistryEntry,
8912    root_str: &str,
8913    upload_root: &str,
8914    upload_name_suffix: Option<&str>,
8915) -> bool {
8916    if entry.input_roots.iter().any(|r| r == root_str) {
8917        return true;
8918    }
8919    if let Some(suffix) = upload_name_suffix {
8920        return entry
8921            .input_roots
8922            .iter()
8923            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8924    }
8925    false
8926}
8927
8928async fn project_history_handler(
8929    State(state): State<AppState>,
8930    Query(query): Query<ProjectHistoryQuery>,
8931) -> Response {
8932    let path = query.path.unwrap_or_default();
8933    let resolved = resolve_input_path(&path);
8934    let root_str = resolved.to_string_lossy().replace('\\', "/");
8935
8936    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
8937    // The UUID is freshly generated for every upload, so an exact root_str match never finds
8938    // previous scans of the same project. Fall back to matching by project name within the
8939    // uploads staging directory so Scan History populates correctly across uploads.
8940    let upload_root = std::env::temp_dir()
8941        .join("oxide-sloc-uploads")
8942        .to_string_lossy()
8943        .replace('\\', "/");
8944    let upload_name_suffix: Option<String> =
8945        if state.server_mode && root_str.starts_with(&upload_root) {
8946            resolved
8947                .file_name()
8948                .and_then(|n| n.to_str())
8949                .map(|name| format!("/{name}"))
8950        } else {
8951            None
8952        };
8953    let suffix_ref = upload_name_suffix.as_deref();
8954
8955    let entries: Vec<_> = {
8956        let reg = state.registry.lock().await;
8957        reg.entries
8958            .iter()
8959            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8960            .cloned()
8961            .collect()
8962    };
8963    let scan_count = entries.len();
8964    let last = entries.first();
8965    let last_scan_id = last.map(|e| e.run_id.clone());
8966    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8967    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8968    let last_git_branch = last.and_then(|e| e.git_branch.clone());
8969    let last_git_commit = last.and_then(|e| e.git_commit.clone());
8970
8971    Json(ProjectHistoryResponse {
8972        scan_count,
8973        last_scan_id,
8974        last_scan_timestamp,
8975        last_scan_code_lines,
8976        last_git_branch,
8977        last_git_commit,
8978    })
8979    .into_response()
8980}
8981
8982// ── Metrics history API ───────────────────────────────────────────────────────
8983// Protected. Returns a JSON array of lightweight scan snapshots for plotting
8984// trend charts.
8985//
8986// GET /api/metrics/history?root=<path>&limit=<n>
8987
8988#[derive(Deserialize)]
8989struct MetricsHistoryQuery {
8990    root: Option<String>,
8991    limit: Option<usize>,
8992    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
8993    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
8994    submodule: Option<String>,
8995}
8996
8997#[derive(Serialize)]
8998struct MetricsSubmoduleLink {
8999    name: String,
9000    url: String,
9001}
9002
9003#[derive(Serialize)]
9004struct MetricsHistoryEntry {
9005    run_id: String,
9006    run_id_short: String,
9007    timestamp: String,
9008    commit: Option<String>,
9009    branch: Option<String>,
9010    tags: Vec<String>,
9011    nearest_tag: Option<String>,
9012    code_lines: u64,
9013    comment_lines: u64,
9014    blank_lines: u64,
9015    physical_lines: u64,
9016    files_analyzed: u64,
9017    files_skipped: u64,
9018    test_count: u64,
9019    project_label: String,
9020    html_url: Option<String>,
9021    has_pdf: bool,
9022    submodule_links: Vec<MetricsSubmoduleLink>,
9023    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
9024    #[serde(skip_serializing_if = "Option::is_none")]
9025    coverage_line_pct: Option<f64>,
9026}
9027
9028fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9029    let mut links: Vec<MetricsSubmoduleLink> = vec![];
9030    let sub_dir = e
9031        .html_path
9032        .as_ref()
9033        .and_then(|p| p.parent())
9034        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9035    let Some(dir) = sub_dir else { return links };
9036    let Ok(rd) = std::fs::read_dir(dir) else {
9037        return links;
9038    };
9039    for entry_res in rd.flatten() {
9040        let fname = entry_res.file_name();
9041        let fname_str = fname.to_string_lossy();
9042        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9043            let stem = &fname_str[..fname_str.len() - 5];
9044            let display = stem[4..].replace('-', " ");
9045            links.push(MetricsSubmoduleLink {
9046                name: display,
9047                url: format!("/runs/{stem}/{}", e.run_id),
9048            });
9049        }
9050    }
9051    links.sort_by(|a, b| a.name.cmp(&b.name));
9052    links
9053}
9054
9055fn apply_submodule_filter(
9056    base: MetricsHistoryEntry,
9057    filter: &str,
9058    e: &sloc_core::history::RegistryEntry,
9059) -> Option<MetricsHistoryEntry> {
9060    let json_path = e.json_path.as_ref()?;
9061    let json_str = std::fs::read_to_string(json_path).ok()?;
9062    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9063    let sub = run
9064        .submodule_summaries
9065        .iter()
9066        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9067    let safe = sanitize_project_label(&sub.name);
9068    let artifact_key = format!("sub_{safe}");
9069    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9070        || base.html_url.clone(),
9071        |run_dir| {
9072            let sub_path = run_dir.join(format!("{artifact_key}.html"));
9073            if sub_path.exists() {
9074                Some(format!("/runs/{artifact_key}/{}", e.run_id))
9075            } else {
9076                base.html_url.clone()
9077            }
9078        },
9079    );
9080
9081    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
9082    // basic SLOC totals, so test_count and coverage must be computed from file records.
9083    let sub_files: Vec<_> = run
9084        .per_file_records
9085        .iter()
9086        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9087        .collect();
9088    let test_count: u64 = sub_files
9089        .iter()
9090        .map(|r| r.raw_line_categories.test_count)
9091        .sum();
9092    #[allow(clippy::cast_precision_loss)]
9093    let coverage_line_pct: Option<f64> = {
9094        let found: u64 = sub_files
9095            .iter()
9096            .filter_map(|r| r.coverage.as_ref())
9097            .map(|c| u64::from(c.lines_found))
9098            .sum();
9099        let hit: u64 = sub_files
9100            .iter()
9101            .filter_map(|r| r.coverage.as_ref())
9102            .map(|c| u64::from(c.lines_hit))
9103            .sum();
9104        if found > 0 {
9105            let pct = (hit as f64 / found as f64) * 100.0;
9106            Some((pct * 10.0).round() / 10.0)
9107        } else {
9108            None
9109        }
9110    };
9111
9112    Some(MetricsHistoryEntry {
9113        code_lines: sub.code_lines,
9114        comment_lines: sub.comment_lines,
9115        blank_lines: sub.blank_lines,
9116        physical_lines: sub.total_physical_lines,
9117        files_analyzed: sub.files_analyzed,
9118        files_skipped: 0,
9119        test_count,
9120        html_url: sub_html_url,
9121        has_pdf: false,
9122        submodule_links: vec![],
9123        coverage_line_pct,
9124        ..base
9125    })
9126}
9127
9128#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
9129async fn api_metrics_history_handler(
9130    State(state): State<AppState>,
9131    Query(query): Query<MetricsHistoryQuery>,
9132) -> Response {
9133    let limit = query.limit.unwrap_or(50).min(500);
9134    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9135
9136    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9137        let reg = state.registry.lock().await;
9138        reg.entries
9139            .iter()
9140            .filter(|e| {
9141                query.root.as_ref().is_none_or(|root| {
9142                    let resolved = resolve_input_path(root);
9143                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9144                    e.input_roots.iter().any(|r| r == &root_str)
9145                })
9146            })
9147            .take(limit)
9148            .cloned()
9149            .collect()
9150    };
9151
9152    let entries: Vec<MetricsHistoryEntry> = candidate_entries
9153        .into_iter()
9154        .filter_map(|e| {
9155            let tags = e
9156                .git_tags
9157                .as_deref()
9158                .map(|s| {
9159                    s.split(',')
9160                        .map(|t| t.trim().to_string())
9161                        .filter(|t| !t.is_empty())
9162                        .collect()
9163                })
9164                .unwrap_or_default();
9165            let html_url = e
9166                .html_path
9167                .as_ref()
9168                .filter(|p| p.exists())
9169                .map(|_| format!("/runs/html/{}", e.run_id));
9170            let nearest_tag = e.git_nearest_tag.clone();
9171            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9172            let run_id_short: String = e
9173                .run_id
9174                .split('-')
9175                .next_back()
9176                .unwrap_or(&e.run_id)
9177                .chars()
9178                .take(7)
9179                .collect();
9180            let submodule_links = build_entry_submodule_links(&e);
9181            #[allow(clippy::cast_precision_loss)]
9182            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9183                let pct = (e.summary.coverage_lines_hit as f64
9184                    / e.summary.coverage_lines_found as f64)
9185                    * 100.0;
9186                Some((pct * 10.0).round() / 10.0)
9187            } else {
9188                None
9189            };
9190            let base = MetricsHistoryEntry {
9191                run_id: e.run_id.clone(),
9192                run_id_short,
9193                timestamp: e.timestamp_utc.to_rfc3339(),
9194                commit: e.git_commit.clone(),
9195                branch: e.git_branch.clone(),
9196                tags,
9197                nearest_tag,
9198                code_lines: e.summary.code_lines,
9199                comment_lines: e.summary.comment_lines,
9200                blank_lines: e.summary.blank_lines,
9201                physical_lines: e.summary.total_physical_lines,
9202                files_analyzed: e.summary.files_analyzed,
9203                files_skipped: e.summary.files_skipped,
9204                test_count: e.summary.test_count,
9205                project_label: e.project_label.clone(),
9206                html_url,
9207                has_pdf,
9208                submodule_links,
9209                coverage_line_pct,
9210            };
9211            if let Some(ref filter) = submodule_filter {
9212                apply_submodule_filter(base, filter, &e)
9213            } else {
9214                Some(base)
9215            }
9216        })
9217        .collect();
9218
9219    Json(entries).into_response()
9220}
9221
9222/// One scan's code churn versus the previous scan of the same project.
9223#[derive(Serialize)]
9224struct ChurnEntry {
9225    run_id: String,
9226    added: i64,
9227    removed: i64,
9228    modified: i64,
9229    unmodified: i64,
9230}
9231
9232// GET /api/metrics/churn?root=<path>&limit=<n>
9233// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
9234// comparing each scan to the previous scan of the same project. Loads per-file JSON
9235// artifacts, so it is intended for export-time use rather than every page load.
9236async fn api_metrics_churn_handler(
9237    State(state): State<AppState>,
9238    Query(query): Query<MetricsHistoryQuery>,
9239) -> Response {
9240    let limit = query.limit.unwrap_or(200).min(500);
9241    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9242        let reg = state.registry.lock().await;
9243        reg.entries
9244            .iter()
9245            .filter(|e| {
9246                query.root.as_ref().is_none_or(|root| {
9247                    let resolved = resolve_input_path(root);
9248                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9249                    e.input_roots.iter().any(|r| r == &root_str)
9250                })
9251            })
9252            .take(limit)
9253            .cloned()
9254            .collect()
9255    };
9256    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9257        std::collections::HashMap::new();
9258    for e in candidate_entries {
9259        by_project
9260            .entry(e.project_label.clone())
9261            .or_default()
9262            .push(e);
9263    }
9264    let mut out: Vec<ChurnEntry> = Vec::new();
9265    for (_proj, mut entries) in by_project {
9266        entries.sort_by_key(|e| e.timestamp_utc);
9267        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9268        for e in &entries {
9269            let curr = e
9270                .json_path
9271                .as_ref()
9272                .and_then(|path| sloc_core::read_json(path).ok());
9273            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9274                let cmp = sloc_core::compute_delta(prev, cur);
9275                out.push(ChurnEntry {
9276                    run_id: e.run_id.clone(),
9277                    added: sum_added_code_lines(&cmp),
9278                    removed: sum_removed_code_lines(&cmp),
9279                    modified: sum_modified_code_lines(&cmp),
9280                    unmodified: sum_unmodified_code_lines(&cmp),
9281                });
9282            } else {
9283                out.push(ChurnEntry {
9284                    run_id: e.run_id.clone(),
9285                    added: 0,
9286                    removed: 0,
9287                    modified: 0,
9288                    unmodified: 0,
9289                });
9290            }
9291            if curr.is_some() {
9292                prev_run = curr;
9293            }
9294        }
9295    }
9296    Json(out).into_response()
9297}
9298
9299// GET /api/metrics/submodules?root=<path>
9300// Returns the union of distinct submodule names found across all saved scan JSON artifacts
9301// for the given project root (or all roots if omitted).
9302#[derive(Deserialize)]
9303struct MetricsSubmodulesQuery {
9304    root: Option<String>,
9305}
9306
9307#[derive(Serialize)]
9308struct SubmoduleEntry {
9309    name: String,
9310    relative_path: String,
9311}
9312
9313async fn api_metrics_submodules_handler(
9314    State(state): State<AppState>,
9315    Query(query): Query<MetricsSubmodulesQuery>,
9316) -> Response {
9317    let json_paths: Vec<std::path::PathBuf> = {
9318        let reg = state.registry.lock().await;
9319        reg.entries
9320            .iter()
9321            .filter(|e| {
9322                query.root.as_ref().is_none_or(|root| {
9323                    let resolved = resolve_input_path(root);
9324                    let root_str = resolved.to_string_lossy().replace('\\', "/");
9325                    e.input_roots.iter().any(|r| r == &root_str)
9326                })
9327            })
9328            .filter_map(|e| e.json_path.clone())
9329            .collect()
9330    };
9331
9332    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9333    let mut result: Vec<SubmoduleEntry> = Vec::new();
9334
9335    for path in &json_paths {
9336        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9337            continue;
9338        };
9339        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9340            continue;
9341        };
9342        for sub in &run.submodule_summaries {
9343            if seen.insert(sub.name.clone()) {
9344                result.push(SubmoduleEntry {
9345                    name: sub.name.clone(),
9346                    relative_path: sub.relative_path.clone(),
9347                });
9348            }
9349        }
9350    }
9351
9352    result.sort_by(|a, b| a.name.cmp(&b.name));
9353    Json(result).into_response()
9354}
9355
9356// ── CI ingest endpoint ────────────────────────────────────────────────────────
9357// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
9358// server stores and displays results without cloning or scanning anything itself.
9359//
9360// POST /api/ingest?label=<optional_display_name>
9361// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
9362// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
9363
9364#[derive(Deserialize)]
9365struct IngestQuery {
9366    label: Option<String>,
9367}
9368
9369#[derive(Serialize)]
9370struct IngestResponse {
9371    run_id: String,
9372    view_url: String,
9373}
9374
9375async fn api_ingest_handler(
9376    State(state): State<AppState>,
9377    Query(q): Query<IngestQuery>,
9378    Json(run): Json<sloc_core::AnalysisRun>,
9379) -> Response {
9380    let label = q.label.unwrap_or_else(|| {
9381        run.input_roots
9382            .first()
9383            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9384    });
9385
9386    let label_for_task = label.clone();
9387    let result = tokio::task::spawn_blocking(move || {
9388        let html = render_html(&run)?;
9389        let run_id = run.tool.run_id.clone();
9390        let run_id_safe = run_id.len() <= 128
9391            && !run_id.is_empty()
9392            && run_id
9393                .chars()
9394                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9395        if !run_id_safe {
9396            anyhow::bail!(
9397                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9398            );
9399        }
9400        let project_label = sanitize_project_label(&label_for_task);
9401        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9402        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9403            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9404            _ => project_label,
9405        };
9406        let (artifacts, _pending_pdf) = persist_run_artifacts(
9407            &run,
9408            &html,
9409            &output_dir,
9410            &label_for_task,
9411            &file_stem,
9412            RunResultContext::default(),
9413        )?;
9414        Ok::<_, anyhow::Error>((run_id, artifacts, run))
9415    })
9416    .await;
9417
9418    match result {
9419        Ok(Ok((run_id, artifacts, run))) => {
9420            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9421            (
9422                StatusCode::CREATED,
9423                Json(IngestResponse {
9424                    view_url: format!("/view-reports?run_id={run_id}"),
9425                    run_id,
9426                }),
9427            )
9428                .into_response()
9429        }
9430        Ok(Err(e)) => error::internal(&format!("{e:#}")),
9431        Err(e) => error::internal(&format!("{e}")),
9432    }
9433}
9434
9435// ── Multi-compare page ────────────────────────────────────────────────────────
9436// GET /multi-compare?runs=id1,id2,id3,...
9437
9438fn html_escape(s: &str) -> String {
9439    s.replace('&', "&amp;")
9440        .replace('<', "&lt;")
9441        .replace('>', "&gt;")
9442        .replace('"', "&quot;")
9443}
9444
9445#[allow(clippy::cast_precision_loss)]
9446fn fmt_num(n: i64) -> String {
9447    let a = n.unsigned_abs();
9448    if a >= 1_000_000 {
9449        let v = n as f64 / 1_000_000.0;
9450        let s = format!("{v:.1}");
9451        format!("{}M", s.trim_end_matches(".0"))
9452    } else if a >= 10_000 {
9453        let v = n as f64 / 1_000.0;
9454        let s = format!("{v:.1}");
9455        format!("{}K", s.trim_end_matches(".0"))
9456    } else {
9457        let sign = if n < 0 { "-" } else { "" };
9458        if a < 1_000 {
9459            return format!("{sign}{a}");
9460        }
9461        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9462    }
9463}
9464
9465fn fmt_comma(n: i64) -> String {
9466    let sign = if n < 0 { "-" } else { "" };
9467    let a = n.unsigned_abs();
9468    if a < 1_000 {
9469        return format!("{sign}{a}");
9470    }
9471    let s = a.to_string();
9472    let bytes = s.as_bytes();
9473    let len = bytes.len();
9474    let mut out = String::with_capacity(len + len / 3);
9475    for (i, &b) in bytes.iter().enumerate() {
9476        if i > 0 && (len - i).is_multiple_of(3) {
9477            out.push(',');
9478        }
9479        out.push(b as char);
9480    }
9481    format!("{sign}{out}")
9482}
9483
9484/// Insert thousands separators into the integer portion of a number's textual form.
9485///
9486/// Works for plain integers (`"266148"` → `"266,148"`), signed values
9487/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
9488/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
9489/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
9490fn group_thousands(s: &str) -> String {
9491    let (sign, rest) = match s.as_bytes().first() {
9492        Some(b'-') => ("-", &s[1..]),
9493        Some(b'+') => ("+", &s[1..]),
9494        _ => ("", s),
9495    };
9496    let (int_part, frac_part) = match rest.split_once('.') {
9497        Some((i, f)) => (i, Some(f)),
9498        None => (rest, None),
9499    };
9500    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9501        return s.to_string();
9502    }
9503    let bytes = int_part.as_bytes();
9504    let len = bytes.len();
9505    let mut grouped = String::with_capacity(len + len / 3);
9506    for (i, &b) in bytes.iter().enumerate() {
9507        if i > 0 && (len - i).is_multiple_of(3) {
9508            grouped.push(',');
9509        }
9510        grouped.push(b as char);
9511    }
9512    frac_part.map_or_else(
9513        || format!("{sign}{grouped}"),
9514        |f| format!("{sign}{grouped}.{f}"),
9515    )
9516}
9517
9518/// Custom Askama filters available to templates in this crate.
9519mod filters {
9520    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
9521    // (a `&self` `execute` method returning `Result`), not on our own source.
9522    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9523    use askama::{Result, Values};
9524
9525    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
9526    ///
9527    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
9528    /// (dashes, "No prior scan", etc.) passes through untouched.
9529    #[askama::filter_fn]
9530    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9531        Ok(super::group_thousands(&value.to_string()))
9532    }
9533}
9534
9535#[derive(Deserialize, Default)]
9536struct MultiCompareQuery {
9537    runs: Option<String>,
9538    /// "super" to show only super-repo files (exclude all submodule files)
9539    scope: Option<String>,
9540    /// Submodule name to narrow the comparison to one submodule
9541    sub: Option<String>,
9542}
9543
9544#[allow(clippy::too_many_lines)]
9545async fn multi_compare_handler(
9546    State(state): State<AppState>,
9547    Query(params): Query<MultiCompareQuery>,
9548    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9549) -> impl IntoResponse {
9550    let run_ids: Vec<String> = params
9551        .runs
9552        .as_deref()
9553        .unwrap_or("")
9554        .split(',')
9555        .map(|s| s.trim().to_string())
9556        .filter(|s| !s.is_empty())
9557        .collect();
9558
9559    if run_ids.len() < 2 {
9560        return Html(
9561            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9562             <a href=\"/compare-scans\">Go back</a></p>",
9563        )
9564        .into_response();
9565    }
9566    if run_ids.len() > 20 {
9567        return Html(
9568            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9569             at once. <a href=\"/compare-scans\">Go back</a></p>",
9570        )
9571        .into_response();
9572    }
9573
9574    // Look up each run_id in the registry.
9575    let entries: Vec<Option<RegistryEntry>> = {
9576        let reg = state.registry.lock().await;
9577        run_ids
9578            .iter()
9579            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9580            .collect()
9581    };
9582
9583    for (i, entry) in entries.iter().enumerate() {
9584        if entry.is_none() {
9585            let html = format!(
9586                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9587                 found. <a href=\"/compare-scans\">Go back</a></p>",
9588                run_ids[i]
9589            );
9590            return Html(html).into_response();
9591        }
9592    }
9593
9594    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9595
9596    for entry in &entries {
9597        if entry.json_path.is_none() {
9598            let html = format!(
9599                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9600                 JSON data — re-run the analysis to enable comparison. \
9601                 <a href=\"/compare-scans\">Go back</a></p>",
9602                entry.run_id
9603            );
9604            return Html(html).into_response();
9605        }
9606    }
9607
9608    // Sort chronologically.
9609    entries.sort_by_key(|e| e.timestamp_utc);
9610
9611    // Load JSON for each entry.
9612    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9613    for entry in &entries {
9614        let path = entry.json_path.as_ref().unwrap();
9615        match read_json(path) {
9616            Ok(r) => runs.push(r),
9617            Err(e) => {
9618                let html = format!(
9619                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9620                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9621                    entry.run_id
9622                );
9623                return Html(html).into_response();
9624            }
9625        }
9626    }
9627
9628    // Collect submodule names from all runs.
9629    let all_sub_names: Vec<String> = {
9630        let mut set = std::collections::BTreeSet::new();
9631        for r in &runs {
9632            for s in &r.submodule_summaries {
9633                set.insert(s.name.clone());
9634            }
9635        }
9636        set.into_iter().collect()
9637    };
9638    let has_submodule_data = !all_sub_names.is_empty();
9639    let active_submodule = params.sub.clone();
9640    let super_scope_active = params.scope.as_deref() == Some("super");
9641
9642    // Narrow per_file_records when a scope is active, then recompute totals.
9643    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9644
9645    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9646    let project_label = entries
9647        .first()
9648        .map_or("", |e| e.project_label.as_str())
9649        .to_string();
9650    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9651    let multi = compute_multi_delta(&run_refs);
9652    let html = multi_compare_page(
9653        &multi,
9654        &project_label,
9655        env!("CARGO_PKG_VERSION"),
9656        &csp_nonce,
9657        has_submodule_data,
9658        &all_sub_names,
9659        &runs_csv,
9660        super_scope_active,
9661        active_submodule.as_deref(),
9662        &entries,
9663    );
9664    // no-store: this page is regenerated on every request and embeds inline JS; a cached
9665    // copy after a rebuild would silently mask UI fixes.
9666    (
9667        [(axum::http::header::CACHE_CONTROL, "no-store")],
9668        Html(html),
9669    )
9670        .into_response()
9671}
9672
9673const fn multi_delta_class(n: i64) -> &'static str {
9674    match n {
9675        1.. => "pos",
9676        ..=-1 => "neg",
9677        0 => "zero",
9678    }
9679}
9680
9681fn multi_fmt_delta(n: i64) -> String {
9682    if n > 0 {
9683        format!("+{n}")
9684    } else {
9685        format!("{n}")
9686    }
9687}
9688
9689/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
9690fn js_escape(s: &str) -> String {
9691    use std::fmt::Write as _;
9692    let mut out = String::with_capacity(s.len() + 2);
9693    for c in s.chars() {
9694        match c {
9695            '"' => out.push_str("\\\""),
9696            '\\' => out.push_str("\\\\"),
9697            '\n' => out.push_str("\\n"),
9698            '\r' => out.push_str("\\r"),
9699            '\t' => out.push_str("\\t"),
9700            c if (c as u32) < 0x20 => {
9701                let _ = write!(out, "\\u{:04x}", c as u32);
9702            }
9703            c => out.push(c),
9704        }
9705    }
9706    out
9707}
9708
9709/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
9710fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9711    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9712        return (
9713            "&mdash;".to_string(),
9714            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9715        );
9716    };
9717    let cd = entry
9718        .git_commit_date
9719        .as_deref()
9720        .and_then(fmt_git_date)
9721        .unwrap_or_else(|| "&mdash;".to_string());
9722    let au = entry.git_author.as_deref().map_or_else(
9723        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
9724        |a| {
9725            format!(
9726                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9727                 <span class=\"cmp-author-handle\"></span></span>",
9728                html_escape(a)
9729            )
9730        },
9731    );
9732    (cd, au)
9733}
9734
9735/// Render the scope badge chip for a scan card header.
9736fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9737    active_sub.map_or_else(
9738        || {
9739            if super_scope_active {
9740                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9741            } else {
9742                "<span class=\"mc-scope-tag mc-scope-full\">\
9743                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9744                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9745                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9746                 <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>\
9747                 </svg> Full scan</span>"
9748                    .to_string()
9749            }
9750        },
9751        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9752    )
9753}
9754
9755/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
9756fn build_mc_scan_strip(
9757    multi: &MultiScanComparison,
9758    entries: &[RegistryEntry],
9759    n: usize,
9760    is_many: bool,
9761    active_sub: Option<&str>,
9762    super_scope_active: bool,
9763    project_label: &str,
9764) -> String {
9765    use std::fmt::Write as _;
9766    let mut scan_strip = String::new();
9767    for (i, pt) in multi.points.iter().enumerate() {
9768        let ts_ms = pt.timestamp.timestamp_millis();
9769        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9770        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9771        let branch = pt.git_branch.as_deref().unwrap_or("");
9772        let report_link = format!("/runs/html/{}", pt.run_id);
9773        let branch_html = if branch.is_empty() {
9774            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
9775        } else {
9776            format!(
9777                "<span class=\"mc-card-branch\">{}</span>",
9778                html_escape(branch)
9779            )
9780        };
9781        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9782        let tags_html = pt
9783            .git_tags
9784            .as_deref()
9785            .filter(|t| !t.is_empty())
9786            .map(|t| {
9787                let chips = t
9788                    .split(',')
9789                    .filter(|s| !s.is_empty())
9790                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9791                    .collect::<Vec<_>>()
9792                    .join(" ");
9793                format!(
9794                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9795                     <span class=\"mc-row-val\">{chips}</span></div>"
9796                )
9797            })
9798            .unwrap_or_default();
9799        let nearest = pt
9800            .git_nearest_tag
9801            .as_deref()
9802            .map(|t| format!("near {}", html_escape(t)))
9803            .unwrap_or_default();
9804        let arrow = if i < n - 1 && !is_many {
9805            "<div class='mc-arrow'>&#8594;</div>"
9806        } else {
9807            ""
9808        };
9809        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9810        let nearest_html = if nearest.is_empty() {
9811            String::new()
9812        } else {
9813            format!(
9814                "<span class=\"mc-card-nearest-wrap\">\
9815                 <span class=\"mc-card-nearest\">{nearest}</span>\
9816                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9817                 </span>"
9818            )
9819        };
9820        write!(
9821            scan_strip,
9822            r#"<div class="mc-card">
9823              <div class="mc-card-header">
9824                <div class="mc-card-num">Scan {num}</div>
9825                <div class="mc-card-project-col">
9826                  <div class="mc-card-project">{project_label}</div>
9827                  {scope_badge}
9828                </div>
9829              </div>
9830              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9831              <div class="mc-card-rows">
9832                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9833                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9834                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9835                <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>
9836                {tags_html}
9837              </div>
9838              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9839            </div>{arrow}"#,
9840            num = i + 1,
9841            commit = html_escape(commit),
9842            commit_date = commit_date_html,
9843            ts_ms = ts_ms,
9844            code = fmt_num(pt.code_lines),
9845            scope_badge = scope_badge,
9846            nearest_html = nearest_html,
9847        )
9848        .unwrap();
9849    }
9850    scan_strip
9851}
9852
9853/// Build the metric progression table (thead + tbody) for multi-compare.
9854#[allow(clippy::too_many_lines)]
9855fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9856    use std::fmt::Write as _;
9857    struct MetricRow<'a> {
9858        label: &'a str,
9859        values: Vec<i64>,
9860        seq_deltas: Vec<i64>,
9861        net_delta: i64,
9862    }
9863    let rows: Vec<MetricRow<'_>> = vec![
9864        MetricRow {
9865            label: "Code Lines",
9866            values: multi.points.iter().map(|p| p.code_lines).collect(),
9867            seq_deltas: multi
9868                .sequential_deltas
9869                .iter()
9870                .map(|d| d.summary.code_lines_delta)
9871                .collect(),
9872            net_delta: multi.total_delta.code_lines_delta,
9873        },
9874        MetricRow {
9875            label: "Files Analyzed",
9876            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9877            seq_deltas: multi
9878                .sequential_deltas
9879                .iter()
9880                .map(|d| d.summary.files_analyzed_delta)
9881                .collect(),
9882            net_delta: multi.total_delta.files_analyzed_delta,
9883        },
9884        MetricRow {
9885            label: "Comment Lines",
9886            values: multi.points.iter().map(|p| p.comment_lines).collect(),
9887            seq_deltas: multi
9888                .sequential_deltas
9889                .iter()
9890                .map(|d| d.summary.comment_lines_delta)
9891                .collect(),
9892            net_delta: multi.total_delta.comment_lines_delta,
9893        },
9894        MetricRow {
9895            label: "Blank Lines",
9896            values: multi.points.iter().map(|p| p.blank_lines).collect(),
9897            seq_deltas: multi
9898                .sequential_deltas
9899                .iter()
9900                .map(|d| d.summary.blank_lines_delta)
9901                .collect(),
9902            net_delta: multi.total_delta.blank_lines_delta,
9903        },
9904        MetricRow {
9905            label: "Tests",
9906            values: multi.points.iter().map(|p| p.test_count).collect(),
9907            seq_deltas: multi
9908                .points
9909                .windows(2)
9910                .map(|pts| pts[1].test_count - pts[0].test_count)
9911                .collect(),
9912            net_delta: multi.points.last().map_or(0, |l| l.test_count)
9913                - multi.points.first().map_or(0, |f| f.test_count),
9914        },
9915    ];
9916    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9917    for i in 0..n {
9918        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9919        if i < n - 1 {
9920            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
9921        }
9922    }
9923    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
9924    let mut metrics_tbody = String::new();
9925    for row in &rows {
9926        metrics_tbody.push_str("<tr>");
9927        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9928        for i in 0..n {
9929            write!(
9930                metrics_tbody,
9931                "<td class='mc-val-col'>{}</td>",
9932                fmt_comma(row.values[i])
9933            )
9934            .unwrap();
9935            if i < n - 1 {
9936                let d = row.seq_deltas[i];
9937                write!(
9938                    metrics_tbody,
9939                    "<td class='mc-delta-col {cls}'>{val}</td>",
9940                    cls = multi_delta_class(d),
9941                    val = multi_fmt_delta(d)
9942                )
9943                .unwrap();
9944            }
9945        }
9946        let nd = row.net_delta;
9947        write!(
9948            metrics_tbody,
9949            "<td class='mc-net-col {cls}'>{val}</td>",
9950            cls = multi_delta_class(nd),
9951            val = multi_fmt_delta(nd)
9952        )
9953        .unwrap();
9954        metrics_tbody.push_str("</tr>");
9955    }
9956    (metrics_thead, metrics_tbody)
9957}
9958
9959/// Build the JS-embeddable points JSON array for the multi-compare chart.
9960fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9961    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9962    for (i, pt) in multi.points.iter().enumerate() {
9963        let commit = pt.git_commit.as_deref().unwrap_or("");
9964        let branch = pt.git_branch.as_deref().unwrap_or("");
9965        let tags = pt.git_tags.as_deref().unwrap_or("");
9966        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9967        let scanned_ms = pt.timestamp.timestamp_millis();
9968        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9969        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9970        let commit_date = entry
9971            .and_then(|e| e.git_commit_date.as_deref())
9972            .and_then(fmt_git_date)
9973            .unwrap_or_default();
9974        let author = entry
9975            .and_then(|e| e.git_author.as_deref())
9976            .unwrap_or("")
9977            .to_string();
9978        let cov = pt
9979            .coverage_line_pct
9980            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9981        parts.push(format!(
9982            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}}}"#,
9983            run_id = js_escape(&pt.run_id),
9984            commit = js_escape(commit),
9985            branch = js_escape(branch),
9986            tags = js_escape(tags),
9987            nearest = js_escape(nearest),
9988            commit_date = js_escape(&commit_date),
9989            author = js_escape(&author),
9990            scanned = js_escape(&scanned),
9991            code = pt.code_lines,
9992            comments = pt.comment_lines,
9993            blank = pt.blank_lines,
9994            files = pt.files_analyzed,
9995            tests = pt.test_count,
9996        ));
9997    }
9998    format!("[{}]", parts.join(","))
9999}
10000
10001/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
10002fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10003    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10004    for row in &multi.file_matrix {
10005        let lang = row.language.as_deref().unwrap_or("");
10006        let codes: Vec<String> = row
10007            .code_per_scan
10008            .iter()
10009            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10010            .collect();
10011        let deltas: Vec<String> = row
10012            .code_delta_per_scan
10013            .iter()
10014            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10015            .collect();
10016        parts.push(format!(
10017            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10018            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10019            status = row.overall_status,
10020            codes = codes.join(","),
10021            deltas = deltas.join(","),
10022            total = row.total_code_delta,
10023        ));
10024    }
10025    format!("[{}]", parts.join(","))
10026}
10027
10028/// Build the column header cells for the file-matrix table.
10029fn build_mc_file_col_headers(n: usize) -> String {
10030    use std::fmt::Write as _;
10031    let mut out = String::new();
10032    for i in 0..n {
10033        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10034        if i < n - 1 {
10035            write!(
10036                out,
10037                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
10038                i + 2
10039            )
10040            .unwrap();
10041        }
10042    }
10043    out
10044}
10045
10046/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
10047fn build_mc_scope_bar(
10048    has_submodule_data: bool,
10049    sub_names: &[String],
10050    runs_csv: &str,
10051    active_sub: Option<&str>,
10052    super_scope_active: bool,
10053) -> String {
10054    use std::fmt::Write as _;
10055    if !has_submodule_data {
10056        return String::new();
10057    }
10058    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10059    let full_active = active_sub.is_none() && !super_scope_active;
10060    let mut bar = format!(
10061        r#"<div class="submod-scope-bar">
10062  <span class="submod-scope-label">
10063    <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>
10064    Scope:
10065  </span>
10066  <div class="submod-scope-divider"></div>
10067  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10068  <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>"#,
10069        full_cls = if full_active { " active" } else { "" },
10070        super_cls = if super_scope_active { " active" } else { "" },
10071    );
10072    for s in sub_names {
10073        let is_active = active_sub == Some(s.as_str());
10074        write!(
10075            bar,
10076            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10077            cls = if is_active { " active" } else { "" },
10078            name_enc = html_escape(s),
10079            name_esc = html_escape(s),
10080        )
10081        .unwrap();
10082    }
10083    bar.push_str("\n</div>");
10084    bar
10085}
10086
10087/// Build the scope-description label shown in the page subtitle.
10088fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10089    active_sub.map_or_else(
10090        || {
10091            if super_scope_active {
10092                "Super-repo only &mdash; ".to_string()
10093            } else {
10094                String::new()
10095            }
10096        },
10097        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
10098    )
10099}
10100
10101#[allow(clippy::too_many_lines)]
10102#[allow(clippy::too_many_arguments)]
10103fn multi_compare_page(
10104    multi: &MultiScanComparison,
10105    project_label: &str,
10106    version: &str,
10107    csp_nonce: &str,
10108    has_submodule_data: bool,
10109    sub_names: &[String],
10110    runs_csv: &str,
10111    super_scope_active: bool,
10112    active_sub: Option<&str>,
10113    entries: &[RegistryEntry],
10114) -> String {
10115    let n = multi.points.len();
10116    let is_many = n > 4;
10117    let mc_strip_class = if is_many {
10118        "mc-strip mc-strip-grid"
10119    } else {
10120        "mc-strip"
10121    };
10122
10123    // ── Scan strip cards ──────────────────────────────────────────────────────
10124    let scan_strip = build_mc_scan_strip(
10125        multi,
10126        entries,
10127        n,
10128        is_many,
10129        active_sub,
10130        super_scope_active,
10131        project_label,
10132    );
10133
10134    // ── Summary metrics table ─────────────────────────────────────────────────
10135    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10136
10137    // ── Chart data and table helpers ──────────────────────────────────────────
10138    let points_json = build_mc_points_json(multi, entries);
10139    let file_matrix_json = build_mc_file_matrix_json(multi);
10140
10141    // Counts for filter tabs
10142    let files_modified = multi
10143        .file_matrix
10144        .iter()
10145        .filter(|f| f.overall_status == "modified")
10146        .count();
10147    let files_added = multi
10148        .file_matrix
10149        .iter()
10150        .filter(|f| f.overall_status == "added")
10151        .count();
10152    let files_removed = multi
10153        .file_matrix
10154        .iter()
10155        .filter(|f| f.overall_status == "removed")
10156        .count();
10157    let files_unchanged = multi
10158        .file_matrix
10159        .iter()
10160        .filter(|f| f.overall_status == "unchanged")
10161        .count();
10162    let total_files = multi.file_matrix.len();
10163
10164    let file_col_headers = build_mc_file_col_headers(n);
10165    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10166    let scope_bar_html = build_mc_scope_bar(
10167        has_submodule_data,
10168        sub_names,
10169        runs_csv,
10170        active_sub,
10171        super_scope_active,
10172    );
10173    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10174    let toast_assets = sloc_toast_assets(csp_nonce);
10175
10176    format!(
10177        r#"<!doctype html>
10178<html lang="en">
10179<head>
10180  <meta charset="utf-8">
10181  <meta name="viewport" content="width=device-width, initial-scale=1">
10182  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10183  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10184  <style nonce="{csp_nonce}">
10185    :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;}}
10186    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10187    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10188    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;}}
10189    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10190    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10191    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10192    .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;}}
10193    @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));}}}}
10194    .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);}}
10195    .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;}}
10196    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10197    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10198    @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;}}}}
10199    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10200    .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));}}
10201    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10202    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10203    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10204    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10205    .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;}}
10206    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10207    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10208    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10209    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10210    .nav-dropdown{{position:relative;display:inline-flex;}}
10211    .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;}}
10212    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10213    .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;}}
10214    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10215    .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);}}
10216    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10217    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10218    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10219    body:not(.dark-theme) .icon-sun{{display:none;}}
10220    body.dark-theme .icon-moon{{display:none;}}
10221    .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;}}
10222    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10223    .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);}}
10224    .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;}}
10225    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10226    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10227    .settings-modal-body{{padding:14px 16px 16px;}}
10228    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10229    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10230    .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;}}
10231    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10232    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10233    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10234    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10235    .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;}}
10236    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10237    .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;}}
10238    .btn-back:hover{{background:var(--line);}}
10239    .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;}}
10240    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;}}
10241    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10242    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10243    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10244    .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;}}
10245    .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;}}
10246    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10247    .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;}}
10248    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10249    body.dark-theme .mc-card{{background:var(--surface-2);}}
10250    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10251    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10252    .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%;}}
10253    .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;}}
10254    .mc-card-commit:hover{{color:var(--oxide);}}
10255    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10256    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10257    .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;}}
10258    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10259    .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;}}
10260    .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;}}
10261    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10262    .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;}}
10263    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10264    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10265    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10266    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10267    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10268    .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);}}
10269    .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);}}
10270    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10271    .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;}}
10272    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10273    .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;}}
10274    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10275    .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;}}
10276    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10277    .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;}}
10278    .submod-scope-btn:hover{{background:var(--line);}}
10279    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10280    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10281    .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;}}
10282    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10283    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10284    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10285    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10286    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10287    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10288    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10289    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10290    .metrics-table .pos{{color:var(--pos);}}
10291    .metrics-table .neg{{color:var(--neg);}}
10292    .metrics-table .zero{{color:var(--muted);}}
10293    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10294    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10295    .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;}}
10296    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10297    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10298    .chart-wrap{{width:100%;overflow-x:auto;}}
10299    #mc-chart{{display:block;width:100%;}}
10300    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10301    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10302    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10303    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10304    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10305    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10306    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10307    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10308    .ic-card-h2-row .ic-card-h2{{margin:0;}}
10309    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10310    .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;}}
10311    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10312    .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;}}
10313    .ic-svg-modal-ov.open{{display:flex;}}
10314    .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);}}
10315    .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);}}
10316    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10317    .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;}}
10318    .ic-svg-modal-close:hover{{background:var(--line);}}
10319    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10320    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10321    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10322    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10323    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10324    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10325    #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;}}
10326    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10327    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10328    .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;}}
10329    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10330    .tab-btn:hover:not(.active){{background:var(--line);}}
10331    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10332    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10333    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10334    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10335    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10336    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10337    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10338    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10339    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10340    .table-wrap{{width:100%;overflow-x:auto;}}
10341    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10342    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10343    #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;}}
10344    #file-table th.left,#file-table td.left{{text-align:left;}}
10345    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10346    .file-delta-col{{color:var(--muted);font-size:11px;}}
10347    .file-net-col{{font-weight:800;}}
10348    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10349    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10350    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10351    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10352    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10353    .status-badge.modified{{background:#fff2d8;color:#926000;}}
10354    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10355    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10356    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10357    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10358    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10359    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10360    tr.row-added td{{background:rgba(26,143,71,0.04);}}
10361    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10362    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10363    tr.row-unchanged td{{color:var(--muted);}}
10364    tr.row-unchanged .status-badge{{opacity:.65;}}
10365    .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;}}
10366    .absent{{color:var(--muted);font-style:italic;}}
10367    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10368    .pagination-info{{font-size:12px;color:var(--muted);}}
10369    .pagination-btns{{display:flex;gap:5px;}}
10370    .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;}}
10371    .pg-btn:hover:not(:disabled){{background:var(--line);}}
10372    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10373    .pg-btn:disabled{{opacity:.35;cursor:default;}}
10374    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;}}
10375    .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;}}
10376    .export-btn:hover{{background:var(--line);}}
10377    .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;}}
10378    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10379    .site-footer a{{color:var(--muted);}}
10380    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;}}
10381    body.pdf-mode{{background:#fff!important;}}
10382    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10383    .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;}}
10384    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10385    .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;}}
10386    .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;}}
10387    .mc-modal-title{{font-size:18px;font-weight:800;}}
10388    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10389    .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;}}
10390    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10391    .mc-modal-body{{padding:18px 22px;}}
10392    .mc-modal-sec{{margin-bottom:20px;}}
10393    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10394    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10395    .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;}}
10396    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10397    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10398    .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;}}
10399    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10400    .mc-modal-row:last-child{{border-bottom:none;}}
10401    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10402    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10403    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10404    .mc-modal-val a:hover{{text-decoration:underline;}}
10405    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10406    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10407    .mc-modal-stat[data-tip]{{cursor:help;}}
10408    #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);}}
10409    .mc-card{{cursor:pointer;}}
10410    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10411  </style>
10412</head>
10413<body>
10414  {loading_overlay}
10415  <div class="background-watermarks" aria-hidden="true">
10416    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10417    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10418    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10419    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10420    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10421    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10422  </div>
10423  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10424  <div class="top-nav">
10425    <div class="top-nav-inner">
10426      <a class="brand" href="/">
10427        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10428        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10429      </a>
10430      <div class="nav-right">
10431        <a class="nav-pill" href="/">Home</a>
10432        <div class="nav-dropdown">
10433          <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>
10434          <div class="nav-dropdown-menu">
10435            <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>
10436          </div>
10437        </div>
10438        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10439        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10440        <div class="nav-dropdown">
10441          <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>
10442          <div class="nav-dropdown-menu">
10443            <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>
10444          </div>
10445        </div>
10446        <div class="server-status-wrap" id="server-status-wrap">
10447          <div class="nav-pill server-online-pill" id="server-status-pill">
10448            <span class="status-dot" id="status-dot"></span>
10449            <span id="server-status-label">Server</span>
10450            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10451          </div>
10452          <div class="server-status-tip">
10453            OxideSLOC is running &mdash; accessible on your network.
10454            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10455          </div>
10456        </div>
10457        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10458          <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>
10459        </button>
10460        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10461          <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>
10462          <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>
10463        </button>
10464      </div>
10465    </div>
10466  </div>
10467
10468  <div class="page">
10469    <!-- Hero header -->
10470    <div class="mc-hero">
10471      <div class="mc-hero-header">
10472        <div>
10473          <div class="mc-title">Multi-Scan Timeline</div>
10474          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
10475          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
10476        </div>
10477        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10478          <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>
10479          <div class="export-group" id="mc-top-export-group">
10480            <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>
10481            <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>
10482          </div>
10483        </div>
10484      </div>
10485      {scope_bar_html}
10486      <!-- Scan strip -->
10487      <div class="{mc_strip_class}">{scan_strip}</div>
10488    </div>
10489
10490    <!-- Summary metrics table -->
10491    <div class="panel">
10492      <div class="panel-title">Metric Progression</div>
10493      <div class="table-wrap">
10494        <table class="metrics-table">
10495          <thead>{metrics_thead}</thead>
10496          <tbody>{metrics_tbody}</tbody>
10497        </table>
10498      </div>
10499    </div>
10500
10501    <!-- Scan Charts -->
10502    <div class="panel" id="mc-charts-panel">
10503      <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10504      <div class="ic-grid">
10505        <!-- Timeline line chart — spans full width -->
10506        <div class="ic-card" style="grid-column:span 2">
10507          <div class="ic-card-h2-row">
10508            <span class="ic-card-h2">Timeline</span>
10509            <div class="chart-toolbar" style="margin:0">
10510              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10511              <button class="chart-metric-btn" data-metric="files">Files</button>
10512              <button class="chart-metric-btn" data-metric="comments">Comments</button>
10513              <button class="chart-metric-btn" data-metric="tests">Tests</button>
10514              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10515            </div>
10516          </div>
10517          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10518        </div>
10519        <!-- Code Metrics: Scan 1 vs Latest -->
10520        <div class="ic-card">
10521          <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>
10522          <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>
10523          <div id="mc-ic-c1"></div>
10524        </div>
10525        <!-- Language Code Delta -->
10526        <div class="ic-card" id="mc-ic-lang-card">
10527          <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>
10528          <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>
10529          <div id="mc-ic-c3"></div>
10530        </div>
10531        <!-- Delta by Metric -->
10532        <div class="ic-card">
10533          <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>
10534          <div id="mc-ic-c2"></div>
10535        </div>
10536        <!-- File Change Distribution -->
10537        <div class="ic-card">
10538          <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>
10539          <div id="mc-ic-c4"></div>
10540        </div>
10541      </div>
10542    </div>
10543
10544    <!-- File matrix table -->
10545    <div class="panel">
10546      <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>
10547      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10548        <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10549          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10550          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10551          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10552          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10553          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10554        </div>
10555        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10556          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
10557          <div class="export-group">
10558          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
10559          <button type="button" class="export-btn" id="export-csv-btn">
10560            <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>
10561            CSV
10562          </button>
10563          <button type="button" class="export-btn" id="mc-file-xls-btn">
10564            <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>
10565            Excel
10566          </button>
10567          </div>
10568        </div>
10569      </div>
10570      <div class="table-wrap">
10571        <table id="file-table">
10572          <thead>
10573            <tr>
10574              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
10575              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
10576              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
10577              {file_col_headers}
10578              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
10579            </tr>
10580          </thead>
10581          <tbody id="file-tbody"></tbody>
10582        </table>
10583      </div>
10584      <div class="pagination">
10585        <span class="pagination-info" id="pg-info"></span>
10586        <div class="pagination-btns" id="pg-btns"></div>
10587        <div style="display:flex;align-items:center;gap:6px;">
10588          <span style="font-size:12px;color:var(--muted)">Show</span>
10589          <select class="per-page" id="per-page-sel">
10590            <option value="25" selected>25 per page</option>
10591            <option value="50">50 per page</option>
10592            <option value="100">100 per page</option>
10593          </select>
10594        </div>
10595      </div>
10596    </div>
10597  </div>
10598
10599  <div id="mc-ic-tt"></div>
10600
10601  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10602    <div class="ic-svg-modal">
10603      <div class="ic-svg-modal-hdr">
10604        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10605        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
10606      </div>
10607      <div id="ic-svg-modal-body"></div>
10608    </div>
10609  </div>
10610
10611  <footer class="site-footer">
10612    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
10613    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10614    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10615    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10616    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
10617  </footer>
10618
10619  <script nonce="{csp_nonce}">
10620  (function(){{
10621    // ── Dark theme ───────────────────────────────────────────────────────────
10622    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10623    var renderInlineCharts=null;
10624    var tt=document.getElementById('theme-toggle');
10625    if(tt)tt.addEventListener('click',function(){{
10626      var on=document.body.classList.toggle('dark-theme');
10627      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10628      renderChart(activeMetric);
10629      if(renderInlineCharts)renderInlineCharts();
10630    }});
10631
10632    // ── Code particles ───────────────────────────────────────────────────────
10633    var container=document.getElementById('code-particles');
10634    if(container){{
10635      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()'];
10636      for(var i=0;i<28;i++){{
10637        (function(idx){{
10638          var el=document.createElement('span');el.className='code-particle';
10639          el.textContent=snips[idx%snips.length];
10640          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10641          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10642          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10643          el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10644          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10645          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10646          container.appendChild(el);
10647        }})(i);
10648      }}
10649    }}
10650
10651    // ── Watermarks ───────────────────────────────────────────────────────────
10652    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10653    if(wms.length){{
10654      var placed=[];
10655      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;}}
10656      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];}}
10657      var half=Math.floor(wms.length/2);
10658      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;}});
10659    }}
10660
10661    // ── Settings / colour scheme modal ───────────────────────────────────────
10662    (function(){{
10663      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'}}];
10664      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);}});}}
10665      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10666      function init(){{
10667        var btn=document.getElementById('settings-btn');if(!btn)return;
10668        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10669        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>';
10670        document.body.appendChild(m);
10671        var g=document.getElementById('scheme-grid');
10672        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);}});
10673        var cl=document.getElementById('settings-close-btn');
10674        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');}});
10675        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10676        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10677      }}
10678      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10679    }})();
10680
10681    // ── Timezone support for scan timestamps ─────────────────────────────────
10682    (function(){{
10683      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);}};
10684      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'';}}}};
10685      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);}});}};
10686      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10687      window.applyTz(storedTz);
10688      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);}});}}}}
10689      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10690    }})();
10691
10692    // ── Data ────────────────────────────────────────────────────────────────
10693    var POINTS={points_json};
10694    var FILES={file_matrix_json};
10695    var N={n};
10696
10697    // ── fmt helper ───────────────────────────────────────────────────────────
10698    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();}}
10699    function fmtFull(n){{return Number(n).toLocaleString();}}
10700    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10701
10702    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10703    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10704    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));}}
10705    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10706    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10707
10708    // ── Timeline chart ───────────────────────────────────────────────────────
10709    var activeMetric='code';
10710    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10711    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10712
10713    function renderChart(metric){{
10714      var svg=document.getElementById('mc-chart');if(!svg)return;
10715      var W=svg.getBoundingClientRect().width||800,H=280;
10716      svg.setAttribute('height',H);
10717      var pad={{l:62,r:20,t:32,b:72}};
10718      var dark=document.body.classList.contains('dark-theme');
10719      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10720      var valid=pts.filter(function(v){{return v!=null;}});
10721      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;}}
10722      var minV=0,maxV=Math.max.apply(null,valid);
10723      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10724      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10725      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10726      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10727      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10728      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10729      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10730      var parts=[];
10731      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10732      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>');}}
10733      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10734      var lineD='';var firstPt=true;
10735      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);}}}}
10736      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10737      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10738      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10739      for(var i=0;i<N;i++){{
10740        if(pts[i]==null)continue;
10741        var cx=xOf(i),cy=yOf(pts[i]);
10742        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10743        var hasTag=p.tags&&p.tags.length>0;
10744        // Permanent Y-value label above the dot
10745        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>');
10746        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+'"/>');
10747        var xanchor=i===0?'start':i===N-1?'end':'middle';
10748        // X-axis label at 2× the original size (18 px)
10749        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>');
10750      }}
10751      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10752      svg.setAttribute('viewBox','0 0 '+W+' '+H);
10753      svg.innerHTML=parts.join('');
10754      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');}});
10755      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10756      svg.onmousemove=function(e){{
10757        var rect=svg.getBoundingClientRect();
10758        var scaleX=W/rect.width;
10759        var mouseX=(e.clientX-rect.left)*scaleX;
10760        var nearest=-1,minDist=Infinity;
10761        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;}}}}
10762        if(nearest<0)return;
10763        var nc=xOf(nearest),ny=yOf(pts[nearest]);
10764        var xhair=svg.querySelector('.mc-xhair');
10765        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10766        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"/>';
10767        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10768        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10769        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>';
10770        var bx=rect.left+(nc/W*rect.width)+18;
10771        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10772        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10773      }};
10774      svg.onmouseleave=function(){{
10775        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10776        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10777      }};
10778    }}
10779
10780    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10781
10782    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10783      btn.addEventListener('click',function(){{
10784        activeMetric=this.dataset.metric;
10785        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10786        this.classList.add('active');
10787        renderChart(activeMetric);
10788      }});
10789    }});
10790    if(typeof ResizeObserver!=='undefined'){{
10791      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10792    }}
10793    renderChart(activeMetric);
10794
10795    // ── File matrix table ────────────────────────────────────────────────────
10796    var activeStatus='';
10797    var currentPage=1;
10798    var perPage=25;
10799    var mcSortCol=null,mcSortAsc=true;
10800
10801    function getFiltered(){{
10802      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10803      if(!mcSortCol)return data;
10804      var asc=mcSortAsc;
10805      return data.slice().sort(function(a,b){{
10806        var va,vb;
10807        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10808        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10809        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10810        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10811        else{{return 0;}}
10812        if(asc)return va<vb?-1:va>vb?1:0;
10813        return va<vb?1:va>vb?-1:0;
10814      }});
10815    }}
10816
10817    function renderFilePage(){{
10818      var filtered=getFiltered();
10819      var total=filtered.length;
10820      var totalPages=Math.max(1,Math.ceil(total/perPage));
10821      if(currentPage>totalPages)currentPage=totalPages;
10822      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10823      var tbody=document.getElementById('file-tbody');if(!tbody)return;
10824      var rows=[];
10825      for(var i=start;i<end;i++){{
10826        var f=filtered[i];
10827        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10828        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10829        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10830        for(var j=0;j<N;j++){{
10831          var cv=f.c[j];
10832          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10833          if(j<N-1){{
10834            var dv=f.d[j+1];
10835            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10836              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10837          }}
10838        }}
10839        var tc=f.t;
10840        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10841        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10842      }}
10843      tbody.innerHTML=rows.join('');
10844
10845      var info=document.getElementById('pg-info');
10846      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10847      renderPgBtns(totalPages);
10848    }}
10849
10850    function renderPgBtns(totalPages){{
10851      var wrap=document.getElementById('pg-btns');if(!wrap)return;
10852      var btns=[];
10853      function mkBtn(label,page,active,disabled){{
10854        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10855        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10856      }}
10857      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
10858      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10859      if(s>1)btns.push(mkBtn('1',1,false,false));
10860      if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10861      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10862      if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">&hellip;</span>');
10863      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10864      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
10865      wrap.innerHTML=btns.join('');
10866      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10867        b.addEventListener('click',function(){{
10868          var pg=parseInt(this.dataset.pg,10);
10869          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10870        }});
10871      }});
10872    }}
10873
10874    // Tab filter
10875    document.querySelectorAll('.tab-btn').forEach(function(btn){{
10876      btn.addEventListener('click',function(){{
10877        activeStatus=this.dataset.status||'';
10878        currentPage=1;
10879        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10880        this.classList.add('active');
10881        renderFilePage();
10882      }});
10883    }});
10884
10885    // Per-page selector
10886    var ppSel=document.getElementById('per-page-sel');
10887    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10888
10889    // ── Column header sort ───────────────────────────────────────────────────
10890    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10891      th.addEventListener('click',function(){{
10892        var col=th.dataset.sortCol;
10893        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10894        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10895          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10896        }});
10897        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10898        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
10899        currentPage=1;renderFilePage();
10900      }});
10901    }});
10902
10903    // Reset button also clears sort
10904    var mcResetBtn=document.getElementById('mc-file-reset-btn');
10905    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10906      mcSortCol=null;mcSortAsc=true;
10907      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10908        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
10909      }});
10910      activeStatus='';currentPage=1;
10911      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10912      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10913      renderFilePage();
10914    }});
10915
10916    renderFilePage();
10917
10918    // ── CSV export ───────────────────────────────────────────────────────────
10919    var exportBtn=document.getElementById('export-csv-btn');
10920    if(exportBtn)exportBtn.addEventListener('click',function(){{
10921      var header=['File','Language','Status'];
10922      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10923      header.push('Net Delta');
10924      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10925      var filtered=getFiltered();
10926      filtered.forEach(function(f){{
10927        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10928        for(var j=0;j<N;j++){{
10929          cols.push(f.c[j]!=null?f.c[j]:'');
10930          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10931        }}
10932        cols.push(f.t);
10933        rows.push(cols.join(','));
10934      }});
10935      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10936      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10937      a.download=mcExportName('csv');a.click();
10938    }});
10939
10940    // ── File matrix extra export buttons ─────────────────────────────────────
10941    (function(){{
10942      var resetBtn=document.getElementById('mc-file-reset-btn');
10943      if(resetBtn)resetBtn.addEventListener('click',function(){{
10944        activeStatus='';currentPage=1;
10945        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10946        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10947        renderFilePage();
10948      }});
10949
10950      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10951      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10952      function mcMakeXlsx(fname){{
10953        var filtered=getFiltered();
10954        var enc=new TextEncoder();
10955        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;}}
10956        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;}}
10957        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10958        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10959        var ss=[],si={{}};
10960        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10961        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
10962        function WS(){{
10963          var R=0,buf=[];
10964          function cl(c){{return String.fromCharCode(65+c);}}
10965          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10966          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10967          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10968          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>';}}
10969          return{{sc:sc,nc:nc,row:row,xml:xml}};
10970        }}
10971        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10972        var proj=mcExportProj();
10973        // \u2500\u2500 Summary sheet \u2500\u2500
10974        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10975        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10976        r1(s1(0,proj,2));
10977        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10978        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
10979        r1('');
10980        r1(s1(0,'SCAN SUMMARY',8));
10981        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));
10982        POINTS.forEach(function(p,i){{
10983          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10984          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));
10985        }});
10986        r1('');
10987        if(POINTS.length>1){{
10988          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10989          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10990          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10991          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)));}};
10992          nr('Code Lines',pf.code,pl.code);
10993          nr('Comment Lines',pf.comments,pl.comments);
10994          nr('Files Analyzed',pf.files,pl.files);
10995          nr('Tests',pf.tests,pl.tests);
10996          r1('');
10997        }}
10998        var cMod=0,cAdd=0,cRem=0,cUnch=0;
10999        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11000        var totF=FILES.length||1;
11001        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11002        r1(s1(0,'FILE CHANGES',8));
11003        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11004        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11005        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11006        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11007        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11008        var lm={{}};
11009        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;}});
11010        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11011        if(langs.length){{
11012          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11013          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11014          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)));}});
11015        }}
11016        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11017        // \u2500\u2500 File Delta sheet \u2500\u2500
11018        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11019        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11020        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);}}
11021        hcells+=s2(hc,'Net Delta',3);
11022        r2(hcells);
11023        filtered.forEach(function(f){{
11024          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11025          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));}}}}
11026          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11027          r2(cells);
11028        }});
11029        var ncols=3+N+(N-1)+1;
11030        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11031        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>';
11032        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11033        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>',
11034          '_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>',
11035          '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>',
11036          '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>',
11037          '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>',
11038          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11039        var zparts=[],zcds=[],zoff=0,znf=0;
11040        ['[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){{
11041          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11042          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]);
11043          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);
11044          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));
11045          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11046          zoff+=entry.length;znf++;
11047        }});
11048        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11049        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]);
11050        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11051        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11052        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11053        out.set(new Uint8Array(eocd),pos);
11054        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11055        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11056      }}
11057
11058      var xlsBtn=document.getElementById('mc-file-xls-btn');
11059      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11060
11061      // File matrix HTML export — interactive: sort by column, filter by status
11062      function mcFileBuildHtml(){{
11063        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11064        var hdrs=['File','Language','Status'];
11065        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11066        hdrs.push('Net \u0394');
11067        var SI=2;
11068        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;}});
11069        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11070        var cnt={{all:allRows.length}};
11071        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11072        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11073        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11074          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11075          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11076          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11077          '.sub{{font-size:12px;color:#99aabb;}}'+
11078          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11079          '.wr{{padding:16px 20px;}}'+
11080          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11081          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11082          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11083          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11084          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11085          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11086          'thead tr{{background:#1a2035;}}'+
11087          '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;}}'+
11088          'th:hover{{background:#2a3050;}}'+
11089          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11090          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11091          'tr:nth-child(even) td{{background:#faf7f4;}}'+
11092          'tr:hover td{{background:#f5f0ea;}}'+
11093          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11094          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11095        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11096        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11097          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11098          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11099          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11100          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11101        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11102          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
11103          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
11104          '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>";}}'+
11105          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11106          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11107          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
11108          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11109          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11110          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11111          '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("")'+
11112          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11113          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11114          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11115          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11116          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11117          'sd=(sc===ci)?-sd:1;sc=ci;'+
11118          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11119          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11120          'render();';
11121        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11122          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11123          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
11124          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11125          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11126          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11127          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11128          '<script>'+inlineJs+'<\/script><\/body><\/html>';
11129      }}
11130
11131      var htmlBtn=document.getElementById('mc-file-html-btn');
11132      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11133        var h=mcFileBuildHtml();
11134        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11135        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11136        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11137      }});
11138
11139      var pdfBtn=document.getElementById('mc-file-pdf-btn');
11140      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11141        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11142      }});
11143    }})();
11144
11145    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11146    (function(){{
11147      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11148      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11149      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11150      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11151      function fmt2(n){{return Number(n).toLocaleString();}}
11152      function px(n){{return Math.round(n);}}
11153      var _tt=document.getElementById('mc-ic-tt');
11154      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11155      function addTT(el){{
11156        if(!el)return;
11157        el.addEventListener('mouseover',function(e){{
11158          var t=e.target.closest('[data-ttl]');
11159          if(t&&_tt){{
11160            var ttl=t.getAttribute('data-ttl');
11161            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11162            _tt.style.display='block';mvTT(e);
11163            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11164            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11165          }} else {{
11166            if(_tt)_tt.style.display='none';
11167            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11168          }}
11169        }});
11170        el.addEventListener('mouseleave',function(){{
11171          if(_tt)_tt.style.display='none';
11172          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11173        }});
11174        el.addEventListener('mousemove',function(e){{mvTT(e);}});
11175      }}
11176      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';}}
11177      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11178      function buildCharts(){{
11179        if(N<2)return;
11180        var cs=getComputedStyle(document.body);
11181        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11182        var textCol=cv('--text','#43342d');
11183        var mutedCol=cv('--muted','#7b675b');
11184        var gFill=cv('--muted-2','#a08777');
11185        var LGY=cv('--line','#e6d0bf');
11186        var axisCol=cv('--line-strong','#d8bfad');
11187        var surf2col=cv('--surface-2','#f4ede4');
11188        var surfCol=cv('--surface','#fff8f0');
11189        var p0=POINTS[0],pLast=POINTS[N-1];
11190        var dark=document.body.classList.contains('dark-theme');
11191        var FADE=dark?'#524238':'#e6d0bf';
11192        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11193        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;}}
11194      var c1mets=[
11195        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11196        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11197        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11198      ];
11199      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11200      // Code Metrics chart — grows to fill the height its grid row settled to (the
11201      // Language Code Delta sibling usually drives that), so it never sits short at
11202      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11203      function drawC1(){{
11204        var C1W=620,C1H=200;
11205        var c1host=document.getElementById('mc-ic-c1');
11206        var c1card=c1host?c1host.closest('.ic-card'):null;
11207        if(c1host&&c1card&&c1host.clientWidth>0){{
11208          var avW=c1host.clientWidth;
11209          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11210          var wantH=availPx*C1W/avW;
11211          if(wantH>C1H)C1H=wantH;
11212        }}
11213        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11214        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11215        for(var gi=1;gi<=4;gi++){{
11216          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11217          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11218          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11219        }}
11220        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11221        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11222        c1mets.forEach(function(m,i){{
11223          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11224          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11225          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11226          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;"/>';
11227          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>';
11228          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;"/>';
11229          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>';
11230          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>';
11231          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>';
11232        }});
11233        c1+='</svg>';
11234        return c1;
11235      }}
11236      // Chart 2: Delta by Metric (net delta first scan to last)
11237      var mets=[
11238        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11239        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11240        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11241      ];
11242      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11243      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;
11244      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11245      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11246      mets.forEach(function(m,i){{
11247        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);
11248        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>';
11249        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;"/>';
11250        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>';}}
11251        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>';}}
11252      }});
11253      c2+='</svg>';
11254      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11255      var lm={{}};
11256      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;}});
11257      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11258      function drawC3(){{
11259        if(!langs.length)return'';
11260        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11261        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;
11262        var c3host=document.getElementById('mc-ic-c3');
11263        var c3card=document.getElementById('mc-ic-lang-card');
11264        var C3H=langs.length*30+24;
11265        if(c3host&&c3card&&c3host.clientWidth>0){{
11266          var avW=c3host.clientWidth;
11267          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11268          var wantH=availPx*C3W/avW;
11269          if(wantH>C3H)C3H=wantH;
11270        }}
11271        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11272        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11273        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11274        langs.forEach(function(l,i){{
11275          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);
11276          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11277          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"/>';
11278          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>';}}
11279          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>';}}
11280          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>';
11281        }});
11282        c3+='</svg>';
11283        return c3;
11284      }}
11285      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11286      var fm=0,fa=0,fr=0,fu=0;
11287      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11288      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;}});
11289      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11290      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11291      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11292      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;
11293      if(segs.length===1){{
11294        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"/>';
11295        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11296        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>';
11297      }} else {{
11298        segs.forEach(function(s){{
11299          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11300          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);
11301          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);
11302          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"/>';
11303          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>';}}
11304          ang4+=sw;
11305        }});
11306      }}
11307      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11308      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11309      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11310      segs.forEach(function(s,i){{
11311        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11312        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;"/>';
11313        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>';
11314        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11315      }});
11316      c4+='</svg>';
11317      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11318      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11319      // once at natural height to seed the row, then both are filled to the row the
11320      // grid settled to, so neither sits short at the top of an over-tall cell.
11321      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11322      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11323      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11324      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11325      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>';
11326      if(e1)e1.innerHTML=drawC1();
11327      }}
11328      buildCharts();
11329      renderInlineCharts=buildCharts;
11330      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11331      (function(){{
11332        var ov=document.getElementById('ic-svg-modal-ov');
11333        var body=document.getElementById('ic-svg-modal-body');
11334        var ttl=document.getElementById('ic-svg-modal-title');
11335        var closeBtn=document.getElementById('ic-svg-modal-close');
11336        if(!ov||!body)return;
11337        function close(){{ov.classList.remove('open');body.innerHTML='';}}
11338        function open(srcId,title){{
11339          var src=document.getElementById(srcId);if(!src)return;
11340          ttl.textContent=title||'';
11341          var card=src.closest('.ic-card');
11342          var legHtml='';
11343          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11344          body.innerHTML=legHtml+src.innerHTML;
11345          var svg=body.querySelector('svg');
11346          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11347          addTT(body);
11348          ov.classList.add('open');
11349        }}
11350        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11351          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11352        }});
11353        if(closeBtn)closeBtn.addEventListener('click',close);
11354        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11355        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11356      }})();
11357
11358      // HTML legend hover → highlight matching SVG bars within the SAME card only
11359      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11360        var metric=leg.getAttribute('data-highlight');
11361        var parentCard=leg.closest('.ic-card');
11362        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11363        if(!chartEl)return;
11364        leg.addEventListener('mouseenter',function(){{
11365          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11366            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11367              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11368              x.style.opacity='1';
11369            }} else {{
11370              x.style.opacity='0.28';
11371            }}
11372          }});
11373        }});
11374        leg.addEventListener('mouseleave',function(){{
11375          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11376        }});
11377      }});
11378      // Author handles
11379      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11380
11381      // ── Export helpers ────────────────────────────────────────────────────────
11382      // Fetch one image from the server and return a data-URI Promise
11383      function mcFetchUri(path){{
11384        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11385          return new Promise(function(res){{
11386            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11387          }});
11388        }}).catch(function(){{return '';}});
11389      }}
11390      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11391      function mcInlineImgs(html,cb){{
11392        var paths=[],seen={{}};
11393        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11394        if(!paths.length){{cb(html);return;}}
11395        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11396          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11397          .catch(function(){{cb(html);}});
11398      }}
11399      // Capture full-page HTML with all table rows visible
11400      function mcRawHtml(pdfMode){{
11401        if(pdfMode)document.body.classList.add('pdf-mode');
11402        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11403        var html=document.documentElement.outerHTML;
11404        perPage=s;currentPage=p;renderFilePage();
11405        if(pdfMode)document.body.classList.remove('pdf-mode');
11406        return html;
11407      }}
11408
11409      // HTML export (full page with inlined images)
11410      function mcDoHtml(btn,fname){{
11411        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11412        mcInlineImgs(mcRawHtml(false),function(html){{
11413          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11414          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11415          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11416          btn.disabled=false;btn.innerHTML=orig;
11417        }});
11418      }}
11419      // PDF export — comprehensive document-style report: full numbers, all sections
11420      function mcBuildPdfHtml(){{
11421        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11422        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11423        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11424        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>';}}
11425        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11426        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11427        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)));}}
11428        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11429        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11430        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11431        // Header/footer flow in document order (NOT position:fixed) — a fixed
11432        // header repeats every printed page in Chromium and overlaps the content
11433        // below it, swallowing the first rows of pages 2+ and clipping the cards
11434        // on page 1. The table <thead> repeats per page natively, so every row
11435        // stays visible.
11436        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11437          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11438          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11439          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11440          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11441          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11442          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11443          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11444          '.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;}}'+
11445          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11446          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11447          '.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;}}'+
11448          '.body{{padding:12px 18px 0;}}'+
11449          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11450          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11451          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11452          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11453          '.sec{{margin-bottom:10px;}}'+
11454          '.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;}}'+
11455          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11456          '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;}}'+
11457          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11458          'tr:nth-child(even) td{{background:#faf8f6;}}';
11459        // ── Metric Progression ────────────────────────────────────────────────
11460        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11461        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11462        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>';
11463        if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11464        if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11465        var progRows=POINTS.map(function(pt,i){{
11466          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)));
11467          var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11468            '<td style="text-align:right">'+full(pt.code)+'</td>'+
11469            '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11470            '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11471            '<td style="text-align:right">'+full(pt.files)+'</td>';
11472          if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
11473          if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
11474          return r+'</tr>';
11475        }}).join('');
11476        // ── Scan-to-scan changes ──────────────────────────────────────────────
11477        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11478          var prev=POINTS[i];
11479          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11480          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11481          return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11482            '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11483            '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11484            '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11485            '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11486        }}).join(''):'';
11487        // ── File matrix (top 50 by |total delta|) ────────────────────────────
11488        var fmSection='';
11489        if(FILES&&FILES.length){{
11490          // Hard cap on per-scan columns so the table never overflows the page width.
11491          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11492          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11493          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11494          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11495          fmHdr+='<th style="text-align:right">Total \u0394</th>';
11496          var fmRows=topFiles.map(function(f){{
11497            var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11498            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>';
11499            cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11500            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11501            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>';
11502          }}).join('');
11503          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11504          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11505            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11506        }}
11507        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11508          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11509          '<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>'+
11510
11511          '<div class="body">'+
11512          '<div class="sg">'+
11513          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11514            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
11515          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11516            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
11517          (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>':
11518            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
11519          '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11520          '</div>'+
11521          '<div class="sec"><p class="sh">Metric Progression</p>'+
11522          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11523          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11524          '<table><thead><tr><th style="text-align:center">Scans</th>'+
11525          '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11526          '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11527          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11528          fmSection+
11529          '</div>'+
11530          '<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>'+
11531          '</body></html>';
11532      }}
11533      function mcDoPdf(btn){{
11534        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11535      }}
11536
11537      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11538      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11539      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11540      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11541      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11542      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11543      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11544      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11545      if(location.protocol==='file:'){{
11546        [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';}}}} );
11547        [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';}}}} );
11548      }}
11549    }})();
11550    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11551    (function(){{
11552      function $(id){{return document.getElementById(id);}}
11553      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
11554      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11555      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11556      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11557      function openModal(idx){{
11558        var ov=$('mc-modal-overlay');if(!ov)return;
11559        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11560        if(idx<0||idx>=N)return;
11561        var pt=POINTS[idx];
11562        titleEl.textContent='Scan '+(idx+1);
11563        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11564        subEl.textContent=lbl;
11565        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11566          '<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>'+
11567          '<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>'+
11568          '<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>'+
11569          '<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>'+
11570          (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>':'')+
11571          (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>':'')+
11572          '</div></div>';
11573        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11574          (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>':'')+
11575          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11576          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11577          (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>':'')+
11578          (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>':'')+
11579          (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>':'')+
11580          (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>':'')+
11581          '<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>'+
11582          '</div>';
11583        var dHtml='';
11584        if(idx>0){{
11585          var prev=POINTS[idx-1];
11586          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11587          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11588            '<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>'+
11589            '<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>'+
11590            '<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>'+
11591            '</div></div>';
11592        }}
11593        bodyEl.innerHTML=sHtml+iHtml+dHtml;
11594        ov.classList.add('open');document.body.style.overflow='hidden';
11595      }}
11596      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11597      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11598      document.addEventListener('click',function(e){{
11599        if(!e.target||!e.target.closest)return;
11600        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11601        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11602        var card=e.target.closest('.mc-card');
11603        if(!card)return;
11604        if(e.target.closest('a'))return;
11605        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11606        var i=cards.indexOf(card);
11607        if(i>=0)openModal(i);
11608      }});
11609      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11610      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11611      var statTip=null;
11612      document.addEventListener('mousemove',function(e){{
11613        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11614        if(!box){{if(statTip)statTip.style.display='none';return;}}
11615        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11616        var tip=box.getAttribute('data-tip')||'';
11617        if(statTip.textContent!==tip)statTip.textContent=tip;
11618        statTip.style.display='block';
11619        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11620        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11621        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11622        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11623      }});
11624      (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');}})();
11625    }})();
11626  }})();
11627  </script>
11628  <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]';
11629  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;}}
11630  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>
11631  <!-- Scan card detail modal -->
11632  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11633    <div class="mc-modal" id="mc-modal">
11634      <div class="mc-modal-head">
11635        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11636        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
11637      </div>
11638      <div class="mc-modal-body" id="mc-modal-body"></div>
11639    </div>
11640  </div>
11641  {toast_assets}
11642</body>
11643</html>"#,
11644        project_label = html_escape(project_label),
11645        n = n,
11646        scan_strip = scan_strip,
11647        mc_strip_class = mc_strip_class,
11648        metrics_thead = metrics_thead,
11649        metrics_tbody = metrics_tbody,
11650        file_col_headers = file_col_headers,
11651        total_files = total_files,
11652        files_modified = files_modified,
11653        files_added = files_added,
11654        files_removed = files_removed,
11655        files_unchanged = files_unchanged,
11656        points_json = points_json,
11657        file_matrix_json = file_matrix_json,
11658        nav_compare_active = nav_compare_active,
11659        version = version,
11660        csp_nonce = csp_nonce,
11661        scope_bar_html = scope_bar_html,
11662        scope_label = scope_label,
11663        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11664    )
11665}
11666
11667// ── Trend report page ─────────────────────────────────────────────────────────
11668// Protected. Interactive time-series chart page that loads scan history via
11669// /api/metrics/history and renders a vanilla-SVG line chart.
11670//
11671// GET /trend-reports
11672
11673#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
11674async fn trend_report_handler(
11675    State(state): State<AppState>,
11676    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11677) -> Response {
11678    auto_scan_watched_dirs(&state).await;
11679
11680    let watched_dirs_list: Vec<String> = {
11681        let wd = state.watched_dirs.lock().await;
11682        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11683    };
11684
11685    // Collect distinct project roots for the root selector dropdown.
11686    let roots: Vec<String> = {
11687        let reg = state.registry.lock().await;
11688        let mut seen = std::collections::BTreeSet::new();
11689        reg.entries
11690            .iter()
11691            .flat_map(|e| e.input_roots.iter().cloned())
11692            .filter(|r| seen.insert(r.clone()))
11693            .collect()
11694    };
11695
11696    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11697    let nonce = &csp_nonce;
11698    let version = env!("CARGO_PKG_VERSION");
11699    let toast_assets = sloc_toast_assets(nonce);
11700
11701    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
11702    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
11703    // of interactive controls — folder watching is managed by the host administrator.
11704    let watched_dirs_html: String = if state.server_mode {
11705        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()
11706    } else {
11707        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11708            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11709                .to_string()
11710        } else {
11711            watched_dirs_list
11712                .iter()
11713                .fold(String::new(), |mut s, d| {
11714                    use std::fmt::Write as _;
11715                    let escaped =
11716                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
11717                    write!(
11718                        s,
11719                        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>"#
11720                    ).expect("write to String is infallible");
11721                    s
11722                })
11723        };
11724        format!(
11725            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>"#
11726        )
11727    };
11728
11729    let html = format!(
11730        r##"<!doctype html>
11731<html lang="en">
11732<head>
11733  <meta charset="utf-8" />
11734  <meta name="viewport" content="width=device-width, initial-scale=1" />
11735  <title>OxideSLOC | Trend Reports</title>
11736  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11737  <style nonce="{nonce}">
11738    :root {{
11739      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11740      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11741      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11742      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11743      --info-bg:#eef3ff; --info-text:#4467d8;
11744    }}
11745    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11746    *{{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;}}
11747    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11748    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11749    .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;}}
11750    @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));}}}}
11751    .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);}}
11752    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11753    .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));}}
11754    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11755    .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;}}
11756    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11757    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11758    @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; }} }}
11759    .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;}}
11760    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11761    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11762    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11763    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11764    .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;}}
11765    .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;}}
11766    .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;}}
11767    .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;}}
11768    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11769    .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);}}
11770    .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;}}
11771    .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;}}
11772    .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;}}
11773    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11774    .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;}}
11775    .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);}}
11776    .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;}}
11777    .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;}}
11778    .tz-select:focus{{border-color:var(--oxide);}}
11779    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11780    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11781    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11782    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11783    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11784    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11785    .trend-title-block{{flex:1;min-width:0;}}
11786    .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;}}
11787    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11788    .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;}}
11789    .chart-select:focus{{border-color:var(--accent);}}
11790    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11791    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11792    .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);}}
11793    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11794    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11795    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11796    .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);}}
11797    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11798    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11799    .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;}}
11800    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11801    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11802    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11803    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11804    .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;}}
11805    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11806    .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;}}
11807    .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);}}
11808    .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;}}
11809    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11810    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11811    .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);}}
11812    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11813    .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;}}
11814    .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;}}
11815    .data-table tr:last-child td{{border-bottom:none;}}
11816    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11817    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11818    .table-wrap{{width:100%;overflow-x:auto;}}
11819    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11820    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11821    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11822    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11823    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11824    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11825    .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;}}
11826    .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;}}
11827    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11828    .pagination-info{{font-size:13px;color:var(--muted);}}
11829    .pagination-btns{{display:flex;gap:6px;}}
11830    .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;}}
11831    .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;}}
11832    #scan-history-table col:nth-child(1){{width:155px;}}
11833    #scan-history-table col:nth-child(2){{width:240px;}}
11834    #scan-history-table col:nth-child(3){{width:82px;}}
11835    #scan-history-table col:nth-child(4){{width:82px;}}
11836    #scan-history-table col:nth-child(5){{width:90px;}}
11837    #scan-history-table col:nth-child(6){{width:90px;}}
11838    #scan-history-table col:nth-child(7){{width:88px;}}
11839    #scan-history-table col:nth-child(8){{width:150px;}}
11840    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11841    .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;}}
11842    .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;}}
11843    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11844    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11845    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11846    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11847    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11848    .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;}}
11849    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11850    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11851    .watched-chip-rm:hover{{color:var(--oxide);}}
11852    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11853    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11854    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11855    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11856    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11857    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11858    a.run-link:hover{{text-decoration:underline;}}
11859    .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);}}
11860    .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);}}
11861    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11862    .metric-num{{font-weight:700;color:var(--text);}}
11863    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11864    .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;}}
11865    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11866    .btn.primary:hover{{opacity:.9;}}
11867    .rpt-btn{{min-width:58px;justify-content:center;}}
11868    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11869    .report-cell{{overflow:visible!important;white-space:normal!important;}}
11870    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11871    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11872    .submod-details summary::-webkit-details-marker{{display:none;}}
11873    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11874    .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;}}
11875    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11876    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11877    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11878    .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;}}
11879    .export-btn:hover{{background:var(--line);}}
11880    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11881    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11882    .site-footer a{{color:var(--muted);}}
11883    .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;}}
11884    .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;}}
11885    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11886    /* Modal system (Retention Policy / Clean-up) */
11887    .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;}}
11888    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11889    .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);}}
11890    .tr-modal{{background:rgba(255,255,255,0.90);}}
11891    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11892    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11893    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11894    .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);}}
11895    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11896    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11897    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11898    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11899    .tr-modal-body{{padding:22px 30px;}}
11900    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11901    .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;}}
11902    .tr-btn:hover{{transform:translateY(-1px);}}
11903    .tr-btn:active{{transform:translateY(0);}}
11904    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11905    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11906    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11907    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11908    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11909    .tr-btn-secondary:hover{{background:var(--line);}}
11910    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11911    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11912  </style>
11913</head>
11914<body>
11915  <div class="background-watermarks" aria-hidden="true">
11916    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11917    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11918    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11919    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11920    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11921    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11922  </div>
11923  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11924  <div class="top-nav">
11925    <div class="top-nav-inner">
11926      <a class="brand" href="/">
11927        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11928        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11929      </a>
11930      <div class="nav-right">
11931        <a class="nav-pill" href="/">Home</a>
11932        <div class="nav-dropdown">
11933          <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>
11934          <div class="nav-dropdown-menu">
11935            <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>
11936          </div>
11937        </div>
11938        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11939        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11940        <div class="nav-dropdown">
11941          <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>
11942          <div class="nav-dropdown-menu">
11943            <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>
11944          </div>
11945        </div>
11946        <div class="server-status-wrap" id="server-status-wrap">
11947          <div class="nav-pill server-online-pill" id="server-status-pill">
11948            <span class="status-dot" id="status-dot"></span>
11949            <span id="server-status-label">Server</span>
11950            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11951          </div>
11952          <div class="server-status-tip">
11953            OxideSLOC is running — accessible on your network.
11954            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11955          </div>
11956        </div>
11957        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11958          <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>
11959        </button>
11960        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11961          <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>
11962          <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>
11963        </button>
11964      </div>
11965    </div>
11966  </div>
11967
11968  <div class="page">
11969    {watched_dirs_html}
11970    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
11971      <div class="scan-overlay-card">
11972        <div class="scan-spinner"></div>
11973        <div class="scan-overlay-text">Scanning folder…</div>
11974        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
11975      </div>
11976    </div>
11977    <style>
11978    .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);}}
11979    .scan-overlay.active{{display:flex;}}
11980    .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;}}
11981    .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;}}
11982    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
11983    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
11984    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
11985    </style>
11986    <div class="summary-strip" id="trend-stats"></div>
11987    <div class="panel">
11988      <div class="trend-header">
11989        <div class="trend-title-block">
11990          <h1>Trend Reports</h1>
11991          <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>
11992          <span class="chart-hint-inline">
11993            <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>
11994            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
11995          </span>
11996        </div>
11997        <div class="chart-actions">
11998          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11999            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12000            Retention Policy
12001          </button>
12002          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12003            <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>
12004            Clean up old runs
12005          </button>
12006          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12007            <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>
12008            Export Excel
12009          </button>
12010          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12011            <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>
12012            Export PNG
12013          </button>
12014          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12015            <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>
12016            Export PDF
12017          </button>
12018        </div>
12019      </div>
12020
12021      <div class="controls-centered">
12022        <label>Project Root:
12023          <select class="chart-select" id="root-sel">
12024            <option value="">All projects</option>
12025          </select>
12026        </label>
12027        <label>Y Metric:
12028          <select class="chart-select" id="y-sel">
12029            <option value="code_lines">Code Lines</option>
12030            <option value="comment_lines">Comment Lines</option>
12031            <option value="blank_lines">Blank Lines</option>
12032            <option value="physical_lines">Physical Lines</option>
12033            <option value="files_analyzed">Files Analyzed</option>
12034          </select>
12035        </label>
12036        <label>X Axis:
12037          <select class="chart-select" id="x-sel">
12038            <option value="time">By Time</option>
12039            <option value="commit" selected>By Commit</option>
12040            <option value="release">By Release</option>
12041            <option value="tag">Tagged Commits</option>
12042          </select>
12043        </label>
12044        <label id="submodule-label" style="display:none;">Submodule:
12045          <select class="chart-select" id="sub-sel">
12046            <option value="">All (project total)</option>
12047          </select>
12048        </label>
12049        <label>Chart Size:
12050          <select class="chart-select" id="scale-sel">
12051            <option value="0.75">Compact</option>
12052            <option value="1.2" selected>Normal</option>
12053            <option value="1.38">Large</option>
12054          </select>
12055        </label>
12056        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
12057      </div>
12058
12059      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12060      <div id="data-table-wrap" style="overflow-x:auto;"></div>
12061    </div>
12062  </div>
12063
12064  <script nonce="{nonce}">
12065    (function() {{
12066      // Theme persistence
12067      var b = document.body;
12068      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12069      var tgl = document.getElementById('theme-toggle');
12070      if (tgl) tgl.addEventListener('click', function() {{
12071        var d = b.classList.toggle('dark-theme');
12072        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12073      }});
12074
12075      // Watermark randomizer
12076      (function() {{
12077        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12078        if (!wms.length) return;
12079        var placed = [];
12080        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;}}
12081        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];}}
12082        var half=Math.floor(wms.length/2);
12083        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;}});
12084      }})();
12085
12086      // Code particles
12087      (function() {{
12088        var container = document.getElementById('code-particles');
12089        if (!container) return;
12090        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'];
12091        for (var i = 0; i < 38; i++) {{
12092          (function(idx) {{
12093            var el = document.createElement('span');
12094            el.className = 'code-particle';
12095            el.textContent = snippets[idx % snippets.length];
12096            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12097            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12098            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12099            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';
12100            container.appendChild(el);
12101          }})(i);
12102        }}
12103      }})();
12104
12105      // Watched folder picker
12106      (function(){{
12107        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');}};
12108        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);
12109      }})();
12110      (function() {{
12111        var btn = document.getElementById('add-watched-btn');
12112        if (!btn) return;
12113        btn.addEventListener('click', function() {{
12114          fetch('/pick-directory?kind=reports')
12115            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12116            .then(function(data) {{
12117              if (!data.cancelled && data.selected_path) {{
12118                var form = document.createElement('form');
12119                form.method = 'POST';
12120                form.action = '/watched-dirs/add';
12121                var ri = document.createElement('input');
12122                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12123                var fi = document.createElement('input');
12124                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12125                form.appendChild(ri); form.appendChild(fi);
12126                document.body.appendChild(form);
12127                if (window.__scanOverlay) window.__scanOverlay();
12128                form.submit();
12129              }}
12130            }})
12131            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12132        }});
12133      }})();
12134
12135      // Settings / color-scheme modal
12136      (function() {{
12137        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'}}];
12138        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);}});}}
12139        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12140        var btn=document.getElementById('settings-btn');if(!btn)return;
12141        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12142        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>';
12143        document.body.appendChild(m);
12144        var g=document.getElementById('scheme-grid');
12145        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);}});
12146        var cl=document.getElementById('settings-close');
12147        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);}});}})();
12148        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');}});
12149        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12150        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12151      }})();
12152    }})();
12153
12154    var ROOTS = {roots_json};
12155    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12156    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12157    var allData = [];
12158
12159    // Populate root selector
12160    var rootSel = document.getElementById('root-sel');
12161    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12162
12163    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();}}
12164    function fmtFull(n){{return Number(n).toLocaleString();}}
12165    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
12166
12167    // Tooltip
12168    var tt = document.createElement('div');
12169    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);';
12170    document.body.appendChild(tt);
12171    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12172    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';}}
12173    function hideTT(){{tt.style.display='none';}}
12174    window.addEventListener('blur',function(){{hideTT();}});
12175    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12176
12177    function statExact(compact, full){{
12178      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12179    }}
12180    function statVal(n){{
12181      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12182    }}
12183
12184    function updateStats(data){{
12185      var statsEl=document.getElementById('trend-stats');
12186      if(!statsEl)return;
12187      if(!data||!data.length){{statsEl.innerHTML='';return;}}
12188      var yKey=document.getElementById('y-sel').value;
12189      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12190      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12191      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12192      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12193      var absDelta=Math.abs(delta);
12194      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12195      var deltaExact=statExact(deltaCompact,deltaFull);
12196      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12197      statsEl.innerHTML=
12198        '<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>'+
12199        '<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>'+
12200        '<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>'+
12201        '<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>';
12202    }}
12203
12204    var subSel = document.getElementById('sub-sel');
12205    var subLabel = document.getElementById('submodule-label');
12206
12207    function populateSubmodules(root){{
12208      if(!subSel||!subLabel)return;
12209      while(subSel.options.length>1)subSel.remove(1);
12210      subSel.value='';
12211      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12212      fetch(url)
12213        .then(function(r){{return r.json();}})
12214        .then(function(subs){{
12215          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12216          subs.forEach(function(s){{
12217            var o=document.createElement('option');
12218            o.value=s.name;
12219            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12220            subSel.appendChild(o);
12221          }});
12222          subLabel.style.display='';
12223        }})
12224        .catch(function(){{subLabel.style.display='none';}});
12225    }}
12226
12227    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12228
12229    function loadAndRender(){{
12230      var root = rootSel.value;
12231      var sub = subSel ? subSel.value : '';
12232      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12233      document.getElementById('data-table-wrap').innerHTML='';
12234      var url = '/api/metrics/history?limit=100'
12235        + (root ? '&root='+encodeURIComponent(root) : '')
12236        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
12237      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12238        allData = data;
12239        render(data);
12240        updateStats(data);
12241      }}).catch(function(){{
12242        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>';
12243      }});
12244    }}
12245
12246    function render(data){{
12247      var yKey = document.getElementById('y-sel').value;
12248      var xMode = document.getElementById('x-sel').value;
12249
12250      // Filter for tag/release mode
12251      var pts = data;
12252      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12253
12254      // Sort oldest-first for the line chart
12255      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12256
12257      var wrap = document.getElementById('chart-wrap');
12258      if(!pts.length){{
12259        var emptyMsg = (xMode === 'tag')
12260          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12261          : 'No scan data found for the selected filters.';
12262        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12263        renderTable([]);
12264        return;
12265      }}
12266
12267      var scaleEl=document.getElementById('scale-sel');
12268      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12269      renderTrendInto(wrap, pts, yKey, xMode, sc);
12270      renderTable(pts, yKey);
12271    }}
12272
12273    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12274    // Shared by the inline chart and the Full View modal so both render identically.
12275    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12276      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12277      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12278      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12279      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;
12280      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12281
12282      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12283
12284      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">';
12285      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>';
12286
12287      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12288
12289      // Grid + Y axis ticks
12290      for(var ti=0;ti<=5;ti++){{
12291        var gy=PT+CH-Math.round(ti/5*CH);
12292        var gv=Math.round(ti/5*maxY);
12293        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12294        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12295      }}
12296
12297      // X axis labels (every N-th point to avoid crowding)
12298      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12299      pts.forEach(function(d,i){{
12300        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12301        if(i%labelEvery===0||i===pts.length-1){{
12302          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)));
12303          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>';
12304        }}
12305      }});
12306
12307      // Axis label
12308      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12309      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>';
12310      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>';
12311
12312      // Area fill + line path
12313      var pathD='';
12314      pts.forEach(function(d,i){{
12315        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12316        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12317        pathD+=(i===0?'M':'L')+x+','+y;
12318      }});
12319      if(pts.length>1){{
12320        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12321        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12322      }}
12323      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12324
12325      // Data points (clickable) + permanent value labels
12326      var showLabels = pts.length <= 40;
12327      var labelEveryN = pts.length > 20 ? 2 : 1;
12328      pts.forEach(function(d,i){{
12329        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12330        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12331        var hasTags=d.tags&&d.tags.length>0;
12332        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12333        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12334        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+'"/>';
12335        if(showLabels && i%labelEveryN===0){{
12336          var lx=x, ly=y-r-5;
12337          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>';
12338        }}
12339      }});
12340
12341      svg+='</svg>';
12342      wrap.innerHTML=svg;
12343
12344      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12345      function lineYAt(mx){{
12346        var n=pts.length;
12347        if(n===0)return PT+CH;
12348        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12349        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12350        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12351        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12352        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12353        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12354        return y0+t*(y1-y0);
12355      }}
12356
12357      // SVG-level mousemove: show the value tooltip only when the pointer is over the
12358      // gradient fill (inside the chart and at/below the line) — never in the empty
12359      // space above the line. Cursor follows the same rule.
12360      (function(){{
12361        var svgEl=wrap.querySelector('svg');
12362        if(!svgEl)return;
12363        svgEl.addEventListener('mousemove',function(e){{
12364          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12365          var rect=svgEl.getBoundingClientRect();
12366          var scaleX=W/Math.max(rect.width,1);
12367          var scaleY=H/Math.max(rect.height,1);
12368          var mouseX=(e.clientX-rect.left)*scaleX;
12369          var mouseY=(e.clientY-rect.top)*scaleY;
12370          var ly=lineYAt(mouseX);
12371          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12372          svgEl.style.cursor='pointer';
12373          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12374          var d=pts[idx];
12375          var val=Number(d[yKey]);
12376          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12377          showTT(e,
12378            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12379            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12380            '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12381          );
12382        }});
12383        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12384      }})();
12385
12386      // Attach point tooltips
12387      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12388        c.addEventListener('mouseover',function(e){{
12389          var d=pts[parseInt(this.dataset.idx)];
12390          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(''):'';
12391          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>':'';
12392          showTT(e,
12393            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12394            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12395            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12396            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12397          );
12398          this.setAttribute('r','8');
12399        }});
12400        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12401        c.addEventListener('mousemove',moveTT);
12402        c.addEventListener('click',function(){{
12403          var d=pts[parseInt(this.dataset.idx)];
12404          if(d.html_url) window.open(d.html_url,'_blank');
12405        }});
12406      }});
12407    }}
12408
12409    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12410    var shProjFilter='', shBranchFilter='';
12411
12412    function fmtPST(isoStr){{
12413      if(!isoStr)return'';
12414      var d=new Date(isoStr);
12415      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12416      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);}}
12417      function p(n){{return n<10?'0'+n:String(n);}}
12418      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++;}}}}
12419      var yr=d.getUTCFullYear();
12420      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12421      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12422      var isDST=d>=dstStart&&d<dstEnd;
12423      var off=isDST?-7*3600*1000:-8*3600*1000;
12424      var lbl=isDST?'PDT':'PST';
12425      var loc=new Date(d.getTime()+off);
12426      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12427    }}
12428
12429    function getShRows(){{
12430      var proj=shProjFilter.toLowerCase().trim();
12431      var branch=shBranchFilter;
12432      return shData.filter(function(d){{
12433        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12434        if(branch&&(d.branch||'')!==branch)return false;
12435        return true;
12436      }});
12437    }}
12438
12439    function renderShPage(){{
12440      var filtered=getShRows();
12441      if(shSortCol){{
12442        filtered.sort(function(a,b){{
12443          var va,vb;
12444          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12445          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12446          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12447          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12448          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12449          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12450        }});
12451      }}
12452      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12453      shPage=Math.min(shPage,totalPages);
12454      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12455      var visible=filtered.slice(start,end);
12456      var tbody=document.getElementById('sh-tbody');
12457      if(!tbody)return;
12458      tbody.innerHTML=visible.map(function(d){{
12459        var tsHtml=esc(fmtPST(d.timestamp));
12460        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>';
12461        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>';
12462        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
12463        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
12464        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12465        var reportCell='';
12466        if(d.html_url){{
12467          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12468          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>';}}
12469          reportCell+='</div>';
12470        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
12471        if(d.submodule_links&&d.submodule_links.length){{
12472          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12473          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12474          reportCell+='</div></details>';
12475        }}
12476        return '<tr>'
12477          +'<td>'+tsHtml+'</td>'
12478          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12479          +'<td>'+runIdHtml+'</td>'
12480          +'<td>'+commitHtml+'</td>'
12481          +'<td>'+branchHtml+'</td>'
12482          +'<td>'+tags+'</td>'
12483          +'<td class="num">'+metricHtml+'</td>'
12484          +'<td class="report-cell">'+reportCell+'</td>'
12485          +'</tr>';
12486      }}).join('');
12487      var pgRange=document.getElementById('sh-pg-range');
12488      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12489      var pgInfo=document.getElementById('sh-pg-info');
12490      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12491      var pgBtns=document.getElementById('sh-pg-btns');
12492      if(pgBtns){{
12493        pgBtns.innerHTML='';
12494        function mkPgBtn(lbl,pg,active,disabled){{
12495          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12496          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12497          return b;
12498        }}
12499        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12500        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12501        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12502        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12503      }}
12504    }}
12505
12506    function wireTableBehavior(){{
12507      var pf=document.getElementById('sh-proj-filter');
12508      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12509      var bf=document.getElementById('sh-branch-filter');
12510      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12511      var rb=document.getElementById('sh-reset-btn');
12512      if(rb)rb.addEventListener('click',function(){{
12513        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12514        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12515        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12516        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');}});
12517        renderShPage();
12518      }});
12519      var pps=document.getElementById('sh-per-page');
12520      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12521      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12522      ths.forEach(function(th){{
12523        th.addEventListener('click',function(e){{
12524          if(e.target.classList.contains('col-resize-handle'))return;
12525          var col=th.dataset.col;
12526          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12527          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12528          th.classList.add('sort-'+shSortOrder);
12529          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12530          shPage=1;renderShPage();
12531        }});
12532      }});
12533      var table=document.getElementById('scan-history-table');
12534      if(!table)return;
12535      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12536      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12537      allThs.forEach(function(th,i){{
12538        var handle=th.querySelector('.col-resize-handle');
12539        if(!handle||!cols[i])return;
12540        var startX,startW;
12541        handle.addEventListener('mousedown',function(e){{
12542          e.stopPropagation();e.preventDefault();
12543          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12544          handle.classList.add('dragging');
12545          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12546          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12547          document.addEventListener('mousemove',onMove);
12548          document.addEventListener('mouseup',onUp);
12549        }});
12550      }});
12551    }}
12552
12553    function renderTable(pts, yKey){{
12554      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12555      var wrap=document.getElementById('data-table-wrap');
12556      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12557      var yLabel=Y_LABELS[yKey]||yKey||'';
12558      shData=pts.slice().reverse();
12559      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12560      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12561      var branches={{}};
12562      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12563      var branchOpts='<option value="">All branches</option>';
12564      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12565      wrap.innerHTML=
12566        '<div class="chart-section-header">SCAN HISTORY</div>'+
12567        '<div class="filter-row">'+
12568          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12569          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12570          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12571        '</div>'+
12572        '<div class="table-wrap">'+
12573        '<table id="scan-history-table" class="data-table">'+
12574        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12575        '<thead><tr id="sh-thead">'+
12576        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12577        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12578        '<th>Run ID<div class="col-resize-handle"></div></th>'+
12579        '<th>Commit<div class="col-resize-handle"></div></th>'+
12580        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
12581        '<th>Tags<div class="col-resize-handle"></div></th>'+
12582        '<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>'+
12583        '<th>Report<div class="col-resize-handle"></div></th>'+
12584        '</tr></thead>'+
12585        '<tbody id="sh-tbody"></tbody>'+
12586        '</table>'+
12587        '</div>'+
12588        '<div class="pagination">'+
12589          '<span class="pagination-info" id="sh-pg-info"></span>'+
12590          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12591          '<div style="display:flex;align-items:center;gap:8px;">'+
12592            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12593            '<select class="filter-select" id="sh-per-page">'+
12594              '<option value="10">10 per page</option>'+
12595              '<option value="25" selected>25 per page</option>'+
12596              '<option value="50">50 per page</option>'+
12597              '<option value="100">100 per page</option>'+
12598            '</select>'+
12599            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12600          '</div>'+
12601        '</div>';
12602      wireTableBehavior();
12603      renderShPage();
12604    }}
12605
12606    function exportXLSX(){{
12607      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12608      var xbtn=document.getElementById('export-xlsx-btn');
12609      var xorig=xbtn?xbtn.innerHTML:'';
12610      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12611      var root=rootSel.value;
12612      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12613      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12614        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12615        buildAndDownloadXLSX(cm);
12616      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12617    }}
12618
12619    function buildAndDownloadXLSX(churnMap){{
12620      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12621      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12622      // (sorted is newest-first), so a given project/commit appears at most once.
12623      var seenPC={{}},dedup=[];
12624      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12625      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12626      var s1R=dedup.map(function(d){{
12627        var c=churnMap[d.run_id]||{{}};
12628        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];
12629      }});
12630      var pm={{}};
12631      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12632      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'];
12633      var s2R=Object.keys(pm).map(function(p){{
12634        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12635        var lat=sc[sc.length-1],fst=sc[0];
12636        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12637        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);
12638        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];
12639      }});
12640      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12641      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12642      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12643      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12644    }}
12645
12646    function buildXLSX(sheets,chartRows,chartRows2){{
12647      function s2b(s){{return new TextEncoder().encode(s);}}
12648      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
12649      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;}}
12650      function crc32(d){{
12651        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;}}}}
12652        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12653      }}
12654      function buildSheet(hdr,rows,drawRid,withCtrl){{
12655        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12656        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12657        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12658        x+='<row r="1">';
12659        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12660        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12661        x+='</row>';
12662        rows.forEach(function(row,ri){{
12663          var rn=ri+2;
12664          x+='<row r="'+rn+'">';
12665          row.forEach(function(cell,ci){{
12666            var addr=col2l(ci+1)+rn;
12667            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12668            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12669          }});
12670          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>";}}
12671          x+='</row>';
12672        }});
12673        x+='</sheetData>';
12674        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12675        return x+'</worksheet>';
12676      }}
12677      function buildChartXML(rows){{
12678        var sn="'Scan History'";
12679        var nr=rows.length,er=nr+1;
12680        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'}}];
12681        var catCol='C',catIdx=2;
12682        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12683        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">';
12684        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12685        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>';
12686        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12687        sd.forEach(function(s,i){{
12688          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12689          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>';
12690          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12691          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>';
12692          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12693          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12694          x+='</c:strCache></c:strRef></c:cat>';
12695          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+'"/>';
12696          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12697          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12698        }});
12699        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12700        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>';
12701        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>';
12702        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12703        return x;
12704      }}
12705      function buildChartXML2(rows){{
12706        var sn="'By Project'";
12707        var nr=rows.length,er=nr+1;
12708        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'}}];
12709        var catCol='A',catIdx=0;
12710        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12711        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">';
12712        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12713        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>';
12714        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12715        sd.forEach(function(s,i){{
12716          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12717          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>';
12718          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12719          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>';
12720          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12721          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12722          x+='</c:strCache></c:strRef></c:cat>';
12723          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+'"/>';
12724          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12725          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12726        }});
12727        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12728        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>';
12729        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>';
12730        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12731        return x;
12732      }}
12733      function buildChartXML3(rows){{
12734        var sn="'Scan History'";
12735        var nr=rows.length,er=nr+1;
12736        var catCol='C',catIdx=2;
12737        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12738        x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12739        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12740        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12741        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12742        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>";
12743        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12744        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>';
12745        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>';
12746        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12747        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12748        x+='</c:strCache></c:strRef></c:cat>';
12749        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+'"/>';
12750        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12751        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12752        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12753        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>';
12754        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>';
12755        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>';
12756        return x;
12757      }}
12758      function buildFocusSheet(drawRid){{
12759        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12760        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12761        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12762        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12763        x+='<sheetData><row r="1">';
12764        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12765        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12766        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
12767        x+='</row></sheetData>';
12768        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>';
12769        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12770        return x+'</worksheet>';
12771      }}
12772      var hasChart=!!(chartRows&&chartRows.length);
12773      var nr=hasChart?chartRows.length:0;
12774      var hasChart2=!!(chartRows2&&chartRows2.length);
12775      var nr2=hasChart2?chartRows2.length:0;
12776      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>';
12777      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"/>';
12778      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12779      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"/>';}}
12780      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"/>';}}
12781      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12782      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>';
12783      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12784      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"/>';}});
12785      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12786      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>';
12787      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12788      wbx+='</sheets></workbook>';
12789      var files=[
12790        {{name:'[Content_Types].xml',data:s2b(ct)}},
12791        {{name:'_rels/.rels',data:s2b(dotrels)}},
12792        {{name:'xl/workbook.xml',data:s2b(wbx)}},
12793        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12794        {{name:'xl/styles.xml',data:s2b(styl)}}
12795      ];
12796      // Chart embedded directly in Scan History (sheet1); By Project is plain
12797      sheets.forEach(function(s,i){{
12798        var sx;
12799        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12800        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12801        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12802      }});
12803      if(hasChart){{
12804        var fromRow=nr+4,toRow=nr+34;
12805        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>')}});
12806        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12807        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">';
12808        drx+='<xdr:twoCellAnchor editAs="twoCell">';
12809        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>';
12810        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>';
12811        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12812        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12813        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12814        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12815        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12816        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12817        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>')}});
12818        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12819        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>')}});
12820        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12821        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">';
12822        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12823        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>';
12824        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>';
12825        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12826        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12827        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12828        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12829        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12830        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12831        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>')}});
12832        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12833      }}
12834      if(hasChart2){{
12835        var fromRow2=nr2+4,toRow2=nr2+36;
12836        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>')}});
12837        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12838        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">';
12839        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12840        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>';
12841        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>';
12842        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12843        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12844        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12845        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12846        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12847        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12848        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>')}});
12849        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12850      }}
12851      var parts=[],offsets=[],total=0;
12852      files.forEach(function(f){{
12853        offsets.push(total);
12854        var nb=s2b(f.name),crc=crc32(f.data);
12855        var h=new DataView(new ArrayBuffer(30+nb.length));
12856        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12857        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12858        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12859        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12860        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12861        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12862        total+=30+nb.length+f.data.length;
12863      }});
12864      var cdStart=total;
12865      files.forEach(function(f,fi){{
12866        var nb=s2b(f.name),crc=crc32(f.data);
12867        var cd=new DataView(new ArrayBuffer(46+nb.length));
12868        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12869        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12870        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12871        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12872        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12873        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12874        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12875      }});
12876      var cdSz=total-cdStart;
12877      var eocd=new DataView(new ArrayBuffer(22));
12878      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12879      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12880      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12881      parts.push(new Uint8Array(eocd.buffer));
12882      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12883      var out=new Uint8Array(sz);var off=0;
12884      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12885      return out.buffer;
12886    }}
12887
12888    function trendTitleParts(){{
12889      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12890      var subSelEl=document.getElementById('sub-sel');
12891      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12892      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12893      var proj=(document.getElementById('root-sel').value)||'All projects';
12894      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12895      var cnt=(allData&&allData.length)||0;
12896      var now=new Date();
12897      function p2(n){{return(n<10?'0':'')+n;}}
12898      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12899      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
12900    }}
12901
12902    function exportPNG(){{
12903      var svgEl=document.querySelector('#chart-wrap svg');
12904      if(!svgEl){{alert('No chart to export yet.');return;}}
12905      var svgStr=new XMLSerializer().serializeToString(svgEl);
12906      var vb=svgEl.viewBox.baseVal,scale=2;
12907      var headerH=84,footerH=36;
12908      var lw=(vb.width||900),lh=(vb.height||380);
12909      var w=lw*scale,h=(lh+headerH+footerH)*scale;
12910      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12911      var url=URL.createObjectURL(blob);
12912      var img=new Image();
12913      var tp=trendTitleParts();
12914      img.onload=function(){{
12915        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12916        var ctx=canvas.getContext('2d');
12917        var cs=getComputedStyle(document.body);
12918        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12919        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12920        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12921        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12922        ctx.scale(scale,scale);
12923        ctx.textBaseline='alphabetic';ctx.textAlign='left';
12924        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12925        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12926        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12927        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;
12928        ctx.drawImage(img,0,headerH);
12929        var fy=headerH+lh;
12930        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;
12931        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12932        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);
12933        ctx.textAlign='left';
12934        URL.revokeObjectURL(url);
12935        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12936      }};
12937      img.src=url;
12938    }}
12939
12940    function exportPDF(){{
12941      var svgEl=document.querySelector('#chart-wrap svg');
12942      if(!svgEl){{alert('No chart to export yet.');return;}}
12943      var tp=trendTitleParts();
12944      var svgStr=new XMLSerializer().serializeToString(svgEl);
12945      var statsEl=document.getElementById('trend-stats');
12946      var statsHtml=statsEl?statsEl.innerHTML:'';
12947      var yK=document.getElementById('y-sel').value;
12948      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12949      var yL=yLabels[yK]||yK;
12950      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12951      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>';
12952      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>';}});
12953      tableHtml+='</tbody></table>';
12954      var css='<style>'
12955        +'*{{box-sizing:border-box;}}'
12956        +'html,body{{margin:0;padding:0;}}'
12957        // Masthead/footer flow in document order — a position:fixed header repeats
12958        // on every printed page in Chromium and hides the rows beneath it on pages
12959        // 2+. The trend table's <thead> repeats per page natively instead.
12960        +'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;}}'
12961        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12962        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12963        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12964        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12965        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12966        +'.rep-body{{padding:22px 34px 0;}}'
12967        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12968        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12969        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12970        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12971        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12972        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12973        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12974        +'.stat-chip-tip{{display:none!important;}}'
12975        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12976        +'.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;}}'
12977        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12978        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12979        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12980        +'.rep-chart svg{{max-width:100%;height:auto;}}'
12981        +'.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;}}'
12982        +'.filter-row{{display:none!important;}}'
12983        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12984        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12985        +'th{{background:#f0e9e0;font-weight:800;}}'
12986        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12987        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12988        +'.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;}}'
12989        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12990        +'</style>';
12991      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12992        +'<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>'
12993        +'<div class="rep-body">'
12994        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12995        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12996        +'<div class="summary-strip">'+statsHtml+'</div>'
12997        +'<div class="rep-chart">'+svgStr+'</div>'
12998        +tableHtml
12999        +'</div>'
13000        +'<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>'
13001        +'</body></html>';
13002      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13003    }}
13004
13005    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13006      var el=document.getElementById(id);
13007      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13008    }});
13009    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13010    // tracks the container like the responsive Chart.js charts do.
13011    var _rsT=null;
13012    window.addEventListener('resize',function(){{
13013      if(_rsT)clearTimeout(_rsT);
13014      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13015    }});
13016    rootSel.addEventListener('change',function(){{
13017      populateSubmodules(rootSel.value);
13018      loadAndRender();
13019    }});
13020    if(subSel)subSel.addEventListener('change',loadAndRender);
13021
13022    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13023    (function(){{
13024      var fvBtn=document.getElementById('tr-chart-fv-btn');
13025      if(!fvBtn)return;
13026      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13027      fvBtn.addEventListener('click',function(){{
13028        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13029        var yKey=document.getElementById('y-sel').value;
13030        var xMode=document.getElementById('x-sel').value;
13031        var pts=allData;
13032        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13033        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13034        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13035        var tp=trendTitleParts();
13036        var ov=document.createElement('div');
13037        ov.className='tr-chart-full-modal';
13038        ov.innerHTML='<div class="tr-chart-full-inner">'
13039          +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13040          +'<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>'
13041          +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13042          +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13043          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13044        document.body.appendChild(ov);
13045        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13046        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13047        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13048        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13049        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13050      }});
13051    }})();
13052
13053    var xlsxBtn=document.getElementById('export-xlsx-btn');
13054    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13055    var pngBtn=document.getElementById('export-png-btn');
13056    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13057    var pdfBtn=document.getElementById('export-pdf-btn');
13058    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13059
13060    // ── Clean-up modal ───────────────────────────────────────────────────────
13061    (function(){{
13062      var triggerBtn=document.getElementById('cleanup-runs-btn');
13063      if(!triggerBtn)return;
13064      var modal=document.createElement('div');
13065      modal.className='tr-modal-backdrop';
13066      modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13067        +'<div class="tr-modal-head">'
13068        +'<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>'
13069        +'<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>'
13070        +'</div>'
13071        +'<div class="tr-modal-body">'
13072        +'<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>'
13073        +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13074        +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13075        +'<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;">'
13076        +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13077        +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13078        +'</div>'
13079        +'<div class="tr-modal-foot">'
13080        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13081        +'<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>'
13082        +'</div></div>';
13083      document.body.appendChild(modal);
13084      triggerBtn.addEventListener('click',function(){{
13085        document.getElementById('cleanup-status').style.display='none';
13086        modal.style.display='flex';
13087      }});
13088      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13089      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13090      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13091        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13092        var confirmBtn=this;
13093        confirmBtn.disabled=true;
13094        var status=document.getElementById('cleanup-status');
13095        status.style.display='block';
13096        status.style.background='#dbeafe';status.style.color='#1e40af';
13097        status.textContent='Deleting\u2026';
13098        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13099        .then(function(resp){{
13100          return resp.json().then(function(d){{
13101            if(resp.ok){{
13102              status.style.background='#dcfce7';status.style.color='#166534';
13103              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13104              setTimeout(function(){{window.location.reload();}},1500);
13105            }}else{{
13106              status.style.background='#fee2e2';status.style.color='#991b1b';
13107              status.textContent='Error: '+(d.error||'Unexpected error');
13108              confirmBtn.disabled=false;
13109            }}
13110          }});
13111        }})
13112        .catch(function(e){{
13113          status.style.background='#fee2e2';status.style.color='#991b1b';
13114          status.textContent='Network error: '+String(e);
13115          confirmBtn.disabled=false;
13116        }});
13117      }});
13118    }})();
13119
13120    // ── Retention policy panel ────────────────────────────────────────────────
13121    (function(){{
13122      var triggerBtn=document.getElementById('retention-policy-btn');
13123      if(!triggerBtn)return;
13124      var modal=document.createElement('div');
13125      modal.className='tr-modal-backdrop';
13126      modal.style.zIndex='9001';
13127      modal.innerHTML=''
13128        +'<div class="tr-modal" style="max-width:640px;">'
13129        +'<div class="tr-modal-head">'
13130        +'<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>'
13131        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13132        +'</div>'
13133        +'<div class="tr-modal-body">'
13134        +'<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>'
13135        +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13136        +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13137        +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13138        +'</div>'
13139        +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13140        +'<div>'
13141        +'<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>'
13142        +'<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;">'
13143        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13144        +'</div>'
13145        +'<div>'
13146        +'<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>'
13147        +'<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;">'
13148        +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13149        +'</div>'
13150        +'</div>'
13151        +'<div style="margin-bottom:20px;">'
13152        +'<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>'
13153        +'<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;">'
13154        +'<option value="1">Every hour</option>'
13155        +'<option value="6">Every 6 hours</option>'
13156        +'<option value="12">Every 12 hours</option>'
13157        +'<option value="24" selected>Every 24 hours</option>'
13158        +'<option value="48">Every 2 days</option>'
13159        +'<option value="72">Every 3 days</option>'
13160        +'<option value="168">Every week</option>'
13161        +'</select>'
13162        +'</div>'
13163        +'<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>'
13164        +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13165        +'</div>'
13166        +'<div class="tr-modal-foot">'
13167        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13168        +'<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>'
13169        +'<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>'
13170        +'</div>'
13171        +'</div>';
13172      document.body.appendChild(modal);
13173
13174      function rpShowStatus(msg,ok){{
13175        var s=document.getElementById('rp-status');
13176        s.style.display='block';
13177        s.style.background=ok?'#dcfce7':'#fee2e2';
13178        s.style.color=ok?'#166534':'#991b1b';
13179        s.textContent=msg;
13180      }}
13181      function fmtAgo(iso){{
13182        if(!iso)return'Never';
13183        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13184        if(diff<60)return diff+'s ago';
13185        if(diff<3600)return Math.floor(diff/60)+'m ago';
13186        if(diff<86400)return Math.floor(diff/3600)+'h ago';
13187        return Math.floor(diff/86400)+'d ago';
13188      }}
13189      function loadPolicy(){{
13190        fetch('/api/cleanup-policy')
13191          .then(function(r){{return r.json();}})
13192          .then(function(d){{
13193            var p=d.policy;
13194            document.getElementById('rp-enabled').checked=p?p.enabled:false;
13195            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13196            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13197            var sel=document.getElementById('rp-interval');
13198            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;}}}}}}
13199            var lr=document.getElementById('rp-last-run');
13200            if(d.last_run_at){{
13201              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'):'');
13202            }}else{{
13203              lr.textContent='Auto-cleanup has not run yet.';
13204            }}
13205          }})
13206          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13207      }}
13208
13209      triggerBtn.addEventListener('click',function(){{
13210        document.getElementById('rp-status').style.display='none';
13211        loadPolicy();
13212        modal.style.display='flex';
13213      }});
13214      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13215      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13216
13217      document.getElementById('rp-save-btn').addEventListener('click',function(){{
13218        var enabled=document.getElementById('rp-enabled').checked;
13219        var ageVal=document.getElementById('rp-max-age').value.trim();
13220        var countVal=document.getElementById('rp-max-count').value.trim();
13221        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13222        if(enabled&&!ageVal&&!countVal){{
13223          rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13224          return;
13225        }}
13226        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13227        var saveBtn=document.getElementById('rp-save-btn');
13228        saveBtn.disabled=true;
13229        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13230          .then(function(r){{
13231            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13232            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13233          }})
13234          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13235          .finally(function(){{saveBtn.disabled=false;}});
13236      }});
13237
13238      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13239        var btn=this;
13240        var orig=btn.innerHTML;
13241        btn.disabled=true;
13242        btn.textContent='Running\u2026';
13243        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13244          .then(function(r){{return r.json();}})
13245          .then(function(d){{
13246            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13247            loadPolicy();
13248          }})
13249          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13250          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13251      }});
13252    }})();
13253
13254    populateSubmodules(rootSel.value);
13255    loadAndRender();
13256
13257    (function randomizeWatermarks() {{
13258      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13259      if (!wms.length) return;
13260      var placed = [];
13261      function tooClose(top, left) {{
13262        for (var i = 0; i < placed.length; i++) {{
13263          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13264          if (dt < 16 && dl < 12) return true;
13265        }}
13266        return false;
13267      }}
13268      function pick(leftBand) {{
13269        for (var attempt = 0; attempt < 50; attempt++) {{
13270          var top = Math.random() * 88 + 2;
13271          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13272          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13273        }}
13274        var top = Math.random() * 88 + 2;
13275        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13276        placed.push([top, left]); return [top, left];
13277      }}
13278      var half = Math.floor(wms.length / 2);
13279      wms.forEach(function (img, i) {{
13280        var pos = pick(i < half);
13281        var size = Math.floor(Math.random() * 100 + 120);
13282        var rot = (Math.random() * 360).toFixed(1);
13283        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13284        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;
13285      }});
13286    }})();
13287    (function spawnCodeParticles() {{
13288      var container = document.getElementById('code-particles');
13289      if (!container) return;
13290      var snippets = [
13291        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13292        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13293        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13294        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13295        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13296      ];
13297      var count = 38;
13298      for (var i = 0; i < count; i++) {{
13299        (function(idx) {{
13300          var el = document.createElement('span');
13301          el.className = 'code-particle';
13302          el.textContent = snippets[idx % snippets.length];
13303          var left = Math.random() * 94 + 2;
13304          var top = Math.random() * 88 + 6;
13305          var dur = (Math.random() * 10 + 9).toFixed(1);
13306          var delay = (Math.random() * 18).toFixed(1);
13307          var rot = (Math.random() * 26 - 13).toFixed(1);
13308          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13309          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13310          container.appendChild(el);
13311        }})(i);
13312      }}
13313    }})();
13314  </script>
13315  <footer class="site-footer">
13316    local code analysis - metrics, history and reports
13317    &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>
13318    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13319    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13320    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13321    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13322  </footer>
13323  <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>
13324  {toast_assets}
13325</body>
13326</html>"##,
13327    );
13328
13329    Html(html).into_response()
13330}
13331
13332fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13333    use std::collections::HashMap;
13334    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13335        return vec![];
13336    }
13337    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13338    for rec in per_file_records {
13339        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13340            let e = totals.entry(lang.display_name().to_string()).or_default();
13341            e.0 += u64::from(cov.lines_found);
13342            e.1 += u64::from(cov.lines_hit);
13343        }
13344    }
13345    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13346    let mut pairs: Vec<(String, f64)> = totals
13347        .into_iter()
13348        .filter(|(_, (found, _))| *found > 0)
13349        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13350        .collect();
13351    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13352    pairs
13353        .iter()
13354        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13355        .collect()
13356}
13357
13358fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13359    let mut high = 0u64;
13360    let mut mid = 0u64;
13361    let mut low = 0u64;
13362    for rec in per_file_records {
13363        if let Some(cov) = &rec.coverage {
13364            if cov.lines_found == 0 {
13365                continue;
13366            }
13367            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13368            if pct >= 80.0 {
13369                high += 1;
13370            } else if pct >= 50.0 {
13371                mid += 1;
13372            } else {
13373                low += 1;
13374            }
13375        }
13376    }
13377    (high, mid, low)
13378}
13379
13380fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13381    let mut arr: Vec<serde_json::Value> = per_file_records
13382        .iter()
13383        .filter_map(|rec| {
13384            rec.coverage.as_ref().map(|cov| {
13385                let line_pct = if cov.lines_found > 0 {
13386                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13387                        / 10.0
13388                } else {
13389                    0.0
13390                };
13391                let fn_pct = if cov.functions_found > 0 {
13392                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13393                        .round()
13394                        / 10.0
13395                } else {
13396                    -1.0
13397                };
13398                serde_json::json!({
13399                    "rel": rec.relative_path,
13400                    "lang": rec.language.map_or("?", |l| l.display_name()),
13401                    "line_pct": line_pct,
13402                    "fn_pct": fn_pct,
13403                    "lhit": cov.lines_hit,
13404                    "lfound": cov.lines_found,
13405                    "fhit": cov.functions_hit,
13406                    "ffound": cov.functions_found,
13407                })
13408            })
13409        })
13410        .collect();
13411    arr.sort_by(|a, b| {
13412        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13413        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13414        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13415    });
13416    arr
13417}
13418
13419#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13420fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13421    let mut langs: Vec<&sloc_core::LanguageSummary> = run
13422        .totals_by_language
13423        .iter()
13424        .filter(|l| l.test_count > 0)
13425        .collect();
13426    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13427    let lang_tests: Vec<serde_json::Value> = langs
13428        .iter()
13429        .map(|l| {
13430            let d = if l.code_lines > 0 {
13431                l.test_count as f64 / l.code_lines as f64 * 1000.0
13432            } else {
13433                0.0
13434            };
13435            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13436                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13437                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13438        })
13439        .collect();
13440    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13441    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13442    let t = &run.summary_totals;
13443    let total_tests = t.test_count;
13444    let density = if t.code_lines > 0 {
13445        total_tests as f64 / t.code_lines as f64 * 1000.0
13446    } else {
13447        0.0
13448    };
13449    let most_tested = langs.first().map_or_else(
13450        || "\u{2014}".to_string(),
13451        |l| l.language.display_name().to_string(),
13452    );
13453    let test_files: u64 = run
13454        .per_file_records
13455        .iter()
13456        .filter(|f| f.raw_line_categories.test_count > 0)
13457        .count() as u64;
13458    let cov_line = if t.coverage_lines_found > 0 {
13459        format!(
13460            "{:.1}",
13461            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13462        )
13463    } else {
13464        "0".to_string()
13465    };
13466    let cov_fn = if t.coverage_functions_found > 0 {
13467        format!(
13468            "{:.1}",
13469            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13470        )
13471    } else {
13472        "0".to_string()
13473    };
13474    let cov_branch = if t.coverage_branches_found > 0 {
13475        format!(
13476            "{:.1}",
13477            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13478        )
13479    } else {
13480        "0".to_string()
13481    };
13482    let has_cov = !cov_arr.is_empty();
13483    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13484    serde_json::json!({
13485        "totals": {
13486            "test_count": total_tests,
13487            "assertions": t.test_assertion_count,
13488            "suites": t.test_suite_count,
13489            "test_files": test_files,
13490            "total_files": t.files_analyzed,
13491            "density_str": format!("{density:.1}"),
13492            "most_tested": most_tested,
13493            "langs_with_tests": langs.len(),
13494            "cov_line": cov_line,
13495            "cov_fn": cov_fn,
13496            "cov_branch": cov_branch,
13497        },
13498        "lang_tests": lang_tests,
13499        "cov": cov_arr,
13500        "cov_tiers": {"high": high, "mid": mid, "low": low},
13501        "file_cov": file_cov_arr,
13502        "has_coverage": has_cov,
13503        "submodules": {},
13504    })
13505}
13506
13507#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13508fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13509    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13510        .language_summaries
13511        .iter()
13512        .filter(|l| l.test_count > 0)
13513        .collect();
13514    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13515    let lang_tests: Vec<serde_json::Value> = langs
13516        .iter()
13517        .map(|l| {
13518            let d = if l.code_lines > 0 {
13519                l.test_count as f64 / l.code_lines as f64 * 1000.0
13520            } else {
13521                0.0
13522            };
13523            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13524                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13525                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13526        })
13527        .collect();
13528    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13529    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13530    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13531    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13532    let density = if sub.code_lines > 0 {
13533        total_tests as f64 / sub.code_lines as f64 * 1000.0
13534    } else {
13535        0.0
13536    };
13537    let most_tested = langs.first().map_or_else(
13538        || "\u{2014}".to_string(),
13539        |l| l.language.display_name().to_string(),
13540    );
13541    serde_json::json!({
13542        "totals": {
13543            "test_count": total_tests,
13544            "assertions": total_assertions,
13545            "suites": total_suites,
13546            "test_files": test_files_approx,
13547            "total_files": sub.files_analyzed,
13548            "density_str": format!("{density:.1}"),
13549            "most_tested": most_tested,
13550            "langs_with_tests": langs.len(),
13551            "cov_line": "0",
13552            "cov_fn": "0",
13553            "cov_branch": "0",
13554        },
13555        "lang_tests": lang_tests,
13556        "cov": [],
13557        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13558        "has_coverage": false,
13559    })
13560}
13561
13562fn compute_cov_json_str(run: &AnalysisRun) -> String {
13563    use std::collections::HashMap;
13564    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13565    for rec in &run.per_file_records {
13566        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13567            let e = totals.entry(lang.display_name().to_string()).or_default();
13568            e.0 += u64::from(cov.lines_found);
13569            e.1 += u64::from(cov.lines_hit);
13570        }
13571    }
13572    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
13573    let mut pairs: Vec<(String, f64)> = totals
13574        .into_iter()
13575        .filter(|(_, (found, _))| *found > 0)
13576        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13577        .collect();
13578    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13579    let parts: Vec<String> = pairs
13580        .iter()
13581        .map(|(lang, pct)| {
13582            let name = lang.replace('"', "\\\"");
13583            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13584        })
13585        .collect();
13586    format!("[{}]", parts.join(","))
13587}
13588
13589fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13590    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13591    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13592}
13593
13594fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13595    let mut entry = build_test_scope_entry(run);
13596    if !run.submodule_summaries.is_empty() {
13597        let subs: serde_json::Map<String, serde_json::Value> = run
13598            .submodule_summaries
13599            .iter()
13600            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13601            .collect();
13602        entry["submodules"] = serde_json::Value::Object(subs);
13603    }
13604    entry
13605}
13606
13607fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13608    let name = l.language.display_name().replace('"', "\\\"");
13609    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
13610    let density = if l.code_lines > 0 {
13611        l.test_count as f64 / l.code_lines as f64 * 1000.0
13612    } else {
13613        0.0
13614    };
13615    format!(
13616        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13617        name = name,
13618        t = l.test_count,
13619        a = l.test_assertion_count,
13620        s = l.test_suite_count,
13621        c = l.code_lines,
13622        d = density,
13623        f = l.files,
13624    )
13625}
13626
13627fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13628    let Some(r) = run else {
13629        return "[]".to_string();
13630    };
13631    let mut langs: Vec<&sloc_core::LanguageSummary> = r
13632        .totals_by_language
13633        .iter()
13634        .filter(|l| l.test_count > 0)
13635        .collect();
13636    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13637    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13638    format!("[{}]", parts.join(","))
13639}
13640
13641/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
13642async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13643    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13644    scope_map.insert(
13645        "__all__".to_string(),
13646        latest_run.map_or_else(
13647            || {
13648                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13649                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13650                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13651                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13652                    "has_coverage":false,"submodules":{}})
13653            },
13654            build_test_scope_entry,
13655        ),
13656    );
13657    let all_roots: Vec<String> = {
13658        let reg = state.registry.lock().await;
13659        let mut seen = std::collections::BTreeSet::new();
13660        reg.entries
13661            .iter()
13662            .flat_map(|e| e.input_roots.iter().cloned())
13663            .filter(|r| seen.insert(r.clone()))
13664            .collect()
13665    };
13666    for root in &all_roots {
13667        let json_path = {
13668            let reg = state.registry.lock().await;
13669            reg.entries
13670                .iter()
13671                .find(|e| e.input_roots.iter().any(|r| r == root))
13672                .and_then(|e| e.json_path.clone())
13673        };
13674        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13675            let json_str = tokio::fs::read_to_string(&p).await.ok();
13676            json_str
13677                .as_deref()
13678                .and_then(|s| serde_json::from_str(s).ok())
13679        } else {
13680            None
13681        };
13682        if let Some(ref run) = run_for_root {
13683            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13684        }
13685    }
13686    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13687}
13688
13689// GET /test-metrics
13690#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
13691#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
13692async fn test_metrics_handler(
13693    State(state): State<AppState>,
13694    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13695) -> Response {
13696    auto_scan_watched_dirs(&state).await;
13697    let watched_dirs_list: Vec<String> = {
13698        let wd = state.watched_dirs.lock().await;
13699        wd.dirs.iter().map(|p| p.display().to_string()).collect()
13700    };
13701    let latest_run: Option<AnalysisRun> = {
13702        let json_path = {
13703            let reg = state.registry.lock().await;
13704            reg.entries.first().and_then(|e| e.json_path.clone())
13705        };
13706        if let Some(p) = json_path {
13707            let json_str = tokio::fs::read_to_string(&p).await.ok();
13708            json_str
13709                .as_deref()
13710                .and_then(|s| serde_json::from_str(s).ok())
13711        } else {
13712            None
13713        }
13714    };
13715
13716    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
13717    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13718
13719    // Build coverage chart JSON (per-language avg line coverage %).
13720    let cov_json: String = latest_run
13721        .as_ref()
13722        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13723        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13724
13725    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
13726    let _cov_tier_json: String = latest_run
13727        .as_ref()
13728        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13729        .map_or_else(
13730            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13731            compute_cov_tier_json_str,
13732        );
13733
13734    let total_tests: u64 = latest_run
13735        .as_ref()
13736        .map_or(0, |r| r.summary_totals.test_count);
13737    let total_assertions: u64 = latest_run
13738        .as_ref()
13739        .map_or(0, |r| r.summary_totals.test_assertion_count);
13740    let total_suites: u64 = latest_run
13741        .as_ref()
13742        .map_or(0, |r| r.summary_totals.test_suite_count);
13743    let total_code: u64 = latest_run
13744        .as_ref()
13745        .map_or(0, |r| r.summary_totals.code_lines);
13746    let workspace_density: f64 = if total_code > 0 {
13747        total_tests as f64 / total_code as f64 * 1000.0
13748    } else {
13749        0.0
13750    };
13751    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13752        r.totals_by_language
13753            .iter()
13754            .filter(|l| l.test_count > 0)
13755            .count()
13756    });
13757    let most_tested: String = latest_run
13758        .as_ref()
13759        .and_then(|r| {
13760            r.totals_by_language
13761                .iter()
13762                .filter(|l| l.test_count > 0)
13763                .max_by_key(|l| l.test_count)
13764        })
13765        .map_or_else(
13766            || "\u{2014}".to_string(),
13767            |l| l.language.display_name().to_string(),
13768        );
13769    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13770        r.per_file_records
13771            .iter()
13772            .filter(|f| f.raw_line_categories.test_count > 0)
13773            .count() as u64
13774    });
13775    let total_files_analyzed: u64 = latest_run
13776        .as_ref()
13777        .map_or(0, |r| r.summary_totals.files_analyzed);
13778    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13779
13780    // Aggregated coverage percentages from summary_totals
13781    let cov_line_pct_str: String = latest_run
13782        .as_ref()
13783        .filter(|r| r.summary_totals.coverage_lines_found > 0)
13784        .map_or_else(
13785            || "0".to_string(),
13786            |r| {
13787                format!(
13788                    "{:.1}",
13789                    r.summary_totals.coverage_lines_hit as f64
13790                        / r.summary_totals.coverage_lines_found as f64
13791                        * 100.0
13792                )
13793            },
13794        );
13795    let cov_fn_pct_str: String = latest_run
13796        .as_ref()
13797        .filter(|r| r.summary_totals.coverage_functions_found > 0)
13798        .map_or_else(
13799            || "0".to_string(),
13800            |r| {
13801                format!(
13802                    "{:.1}",
13803                    r.summary_totals.coverage_functions_hit as f64
13804                        / r.summary_totals.coverage_functions_found as f64
13805                        * 100.0
13806                )
13807            },
13808        );
13809    let cov_branch_pct_str: String = latest_run
13810        .as_ref()
13811        .filter(|r| r.summary_totals.coverage_branches_found > 0)
13812        .map_or_else(
13813            || "0".to_string(),
13814            |r| {
13815                format!(
13816                    "{:.1}",
13817                    r.summary_totals.coverage_branches_hit as f64
13818                        / r.summary_totals.coverage_branches_found as f64
13819                        * 100.0
13820                )
13821            },
13822        );
13823
13824    let cov_no_data_notice = if has_coverage {
13825        String::new()
13826    } else {
13827        String::from(
13828            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13829<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>
13830<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13831  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13832  <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>
13833  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13834  <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>
13835  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13836  <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>
13837  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13838  <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>
13839  <span style="color:var(--muted);font-size:12px;">&middot;</span>
13840  <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>
13841</div>
13842<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13843</div>"#,
13844        )
13845    };
13846
13847    let workspace_density_str = format!("{workspace_density:.1}");
13848    let nonce = &csp_nonce;
13849    let toast_assets = sloc_toast_assets(nonce);
13850    let version = env!("CARGO_PKG_VERSION");
13851
13852    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
13853    // of interactive controls — folder watching is managed by the host administrator.
13854    let watched_dirs_html: String = if state.server_mode {
13855        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()
13856    } else {
13857        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13858            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13859                .to_string()
13860        } else {
13861            watched_dirs_list
13862                .iter()
13863                .fold(String::new(), |mut s, d| {
13864                    use std::fmt::Write as _;
13865                    let escaped =
13866                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
13867                    write!(
13868                        s,
13869                        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>"#
13870                    ).expect("write to String is infallible");
13871                    s
13872                })
13873        };
13874        format!(
13875            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>"#
13876        )
13877    };
13878
13879    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
13880    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13881
13882    let html = format!(
13883        r#"<!doctype html>
13884<html lang="en">
13885<head>
13886  <meta charset="utf-8" />
13887  <meta name="viewport" content="width=device-width, initial-scale=1" />
13888  <title>OxideSLOC | Test Metrics</title>
13889  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13890  <style nonce="{nonce}">
13891    :root {{
13892      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13893      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13894      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13895      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13896      --info-bg:#eef3ff; --info-text:#4467d8;
13897    }}
13898    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13899    *{{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;}}
13900    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13901    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13902    .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;}}
13903    @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));}}}}
13904    .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);}}
13905    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13906    .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));}}
13907    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13908    .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;}}
13909    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13910    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13911    @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; }} }}
13912    .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;}}
13913    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13914    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13915    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13916    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13917    .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;}}
13918    .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;}}
13919    .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;}}
13920    .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;}}
13921    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13922    .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);}}
13923    .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;}}
13924    .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;}}
13925    .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;}}
13926    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13927    .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;}}
13928    .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);}}
13929    .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;}}
13930    .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;}}
13931    .tz-select:focus{{border-color:var(--oxide);}}
13932    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13933    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13934    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13935    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13936    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13937    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13938    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13939    .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);}}
13940    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13941    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13942    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13943    .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;}}
13944    .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;}}
13945    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13946    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13947    .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);}}
13948    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13949    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13950    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13951    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13952    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13953    .chart-canvas-wrap{{position:relative;height:280px;}}
13954    .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;}}
13955    .chart-no-data svg{{opacity:0.35;}}
13956    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13957    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13958    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13959    .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;}}
13960    .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;}}
13961    .data-table tr:last-child td{{border-bottom:none;}}
13962    .data-table tbody tr:hover td{{background:var(--surface-2);}}
13963    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13964    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13965    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13966    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13967    .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;}}
13968    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13969    .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);}}
13970    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13971    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13972    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13973    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13974    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13975    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13976    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13977    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13978    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13979    .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;}}
13980    .chart-select:focus{{border-color:var(--accent);}}
13981    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13982    .trend-canvas-wrap{{position:relative;height:260px;}}
13983    .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;}}
13984    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13985    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13986    .site-footer a{{color:var(--muted);}}
13987    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13988    .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;}}
13989    .btn:hover{{background:var(--surface-2);}}
13990    .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;}}
13991    .export-btn:hover{{background:var(--line);}}
13992    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13993    /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
13994    .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
13995    .scope-export{{margin-left:auto;}}
13996    body.pdf-mode .export-group{{display:none!important;}}
13997    @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
13998    .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;}}
13999    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14000    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14001    .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;}}
14002    .scope-sel:focus{{border-color:var(--accent);}}
14003    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14004    .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;}}
14005    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14006    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14007    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14008    .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;}}
14009    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14010    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14011    .watched-chip-rm:hover{{color:var(--oxide);}}
14012    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14013    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14014    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14015    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14016    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14017    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14018    .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;}}
14019    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14020    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14021    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14022    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14023    .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;}}
14024    .cov-file-search:focus{{border-color:var(--accent);}}
14025    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14026    .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;}}
14027    body.dark-theme .cov-file-search{{background:var(--surface);}}
14028    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14029    .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;}}
14030    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14031    .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;}}
14032    .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);}}
14033    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14034    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14035    .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;}}
14036    .chart-modal-close:hover{{opacity:.7;}}
14037    body.dark-theme .chart-modal{{background:var(--surface);}}
14038  </style>
14039</head>
14040<body>
14041  <div class="background-watermarks" aria-hidden="true">
14042    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14043    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14044    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14045    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14046    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14047    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14048  </div>
14049  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14050  <div class="top-nav">
14051    <div class="top-nav-inner">
14052      <a class="brand" href="/">
14053        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14054        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14055      </a>
14056      <div class="nav-right">
14057        <a class="nav-pill" href="/">Home</a>
14058        <div class="nav-dropdown">
14059          <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>
14060          <div class="nav-dropdown-menu">
14061            <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>
14062          </div>
14063        </div>
14064        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14065        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14066        <div class="nav-dropdown">
14067          <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>
14068          <div class="nav-dropdown-menu">
14069            <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>
14070          </div>
14071        </div>
14072        <div class="server-status-wrap" id="server-status-wrap">
14073          <div class="nav-pill server-online-pill" id="server-status-pill">
14074            <span class="status-dot" id="status-dot"></span>
14075            <span id="server-status-label">Server</span>
14076            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14077          </div>
14078          <div class="server-status-tip">
14079            OxideSLOC is running — accessible on your network.
14080            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14081          </div>
14082        </div>
14083        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14084          <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>
14085        </button>
14086        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14087          <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>
14088          <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>
14089        </button>
14090      </div>
14091    </div>
14092  </div>
14093
14094  <div class="page">
14095    {watched_dirs_html}
14096    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14097      <div class="scan-overlay-card">
14098        <div class="scan-spinner"></div>
14099        <div class="scan-overlay-text">Scanning folder…</div>
14100        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14101      </div>
14102    </div>
14103    <style>
14104    .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);}}
14105    .scan-overlay.active{{display:flex;}}
14106    .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;}}
14107    .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;}}
14108    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14109    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14110    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14111    </style>
14112    <div class="scope-bar">
14113      <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>
14114      <span class="scope-label">Scope</span>
14115      <div class="scope-sel-wrap">
14116        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14117        <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);">
14118          <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>
14119          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14120        </div>
14121      </div>
14122      <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14123      <div class="export-group scope-export" id="tm-export-group">
14124        <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download the whole page (Test Metrics + LCOV Coverage Summary) as an Excel workbook (.xlsx)">
14125          <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>
14126          Export Excel
14127        </button>
14128        <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14129          <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>
14130          Export PNG
14131        </button>
14132        <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14133          <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>
14134          Export PDF
14135        </button>
14136      </div>
14137    </div>
14138    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14139      <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>
14140      <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>
14141      <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>
14142      <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>
14143    </div>
14144    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14145      <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>
14146      <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>
14147      <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>
14148      <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>
14149    </div>
14150
14151    <div class="panel" id="viz-panel">
14152      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14153
14154      <div class="chart-box" style="margin-bottom:18px;">
14155        <div class="chart-box-header">
14156          <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14157          <div style="display:flex;gap:8px;align-items:center;">
14158            <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>
14159            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14160          </div>
14161        </div>
14162        <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>
14163        <div class="trend-controls-bar">
14164          <label>Y Metric:
14165            <select class="chart-select" id="tm-trend-y">
14166              <option value="test_count" selected>Test Definitions</option>
14167              <option value="code_lines">Code Lines</option>
14168            </select>
14169          </label>
14170          <label>X Axis:
14171            <select class="chart-select" id="tm-trend-x">
14172              <option value="commit" selected>By Commit</option>
14173              <option value="time">By Time</option>
14174            </select>
14175          </label>
14176          <label id="tm-sub-label" style="display:none;">Submodule:
14177            <select class="chart-select" id="tm-trend-sub">
14178              <option value="">All (project total)</option>
14179            </select>
14180          </label>
14181          <label>Chart Size:
14182            <select class="chart-select" id="tm-trend-size">
14183              <option value="200">Compact</option>
14184              <option value="260" selected>Normal</option>
14185              <option value="360">Large</option>
14186            </select>
14187          </label>
14188        </div>
14189        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14190        <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14191      </div>
14192
14193      <div class="chart-row">
14194        <div class="chart-box">
14195          <div class="chart-box-header">
14196            <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14197            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14198          </div>
14199          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14200          <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>
14201        </div>
14202        <div class="chart-box">
14203          <div class="chart-box-header">
14204            <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14205            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14206          </div>
14207          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14208          <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>
14209        </div>
14210      </div>
14211
14212      <div class="chart-row">
14213        <div class="chart-box">
14214          <div class="chart-box-header">
14215            <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14216            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14217          </div>
14218          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14219          <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>
14220        </div>
14221        <div class="chart-box" id="suites-chart-box">
14222          <div class="chart-box-header">
14223            <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14224            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
14225          </div>
14226          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14227          <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>
14228        </div>
14229      </div>
14230
14231      <div class="chart-row">
14232        <div class="chart-box">
14233          <div class="chart-box-title">Test Files Breakdown</div>
14234          <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14235          <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>
14236        </div>
14237        <div class="chart-box">
14238          <div class="chart-box-title">Test Composition</div>
14239          <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14240          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14241          <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>
14242        </div>
14243      </div>
14244    </div>
14245
14246    <div class="panel">
14247      <h1>Test Metrics</h1>
14248      <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>
14249
14250      <div class="section-header">Language Breakdown</div>
14251      {cov_no_data_notice}
14252      <div style="overflow-x:auto;">
14253        <table class="data-table" id="lang-table">
14254          <thead><tr>
14255            <th>Language</th>
14256            <th class="num">Test Fns</th>
14257            <th class="num">Assertions</th>
14258            <th class="num">Suites</th>
14259            <th class="num">Code Lines</th>
14260            <th class="num">Files</th>
14261            <th class="num">Density / 1K</th>
14262            <th>Relative Density</th>
14263          </tr></thead>
14264          <tbody id="lang-tbody"></tbody>
14265        </table>
14266      </div>
14267    </div>
14268
14269    <div class="panel" id="cov-panel" style="display:none;">
14270      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14271      <div class="cov-gauge-row" id="cov-gauges">
14272        <div class="cov-gauge-card">
14273          <div class="cov-gauge-label">Line Coverage</div>
14274          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14275          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14276          <div class="cov-gauge-sub">Lines hit / instrumented</div>
14277          <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>
14278        </div>
14279        <div class="cov-gauge-card">
14280          <div class="cov-gauge-label">Function Coverage</div>
14281          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14282          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14283          <div class="cov-gauge-sub">Functions hit / found</div>
14284          <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>
14285        </div>
14286        <div class="cov-gauge-card">
14287          <div class="cov-gauge-label">Branch Coverage</div>
14288          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14289          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14290          <div class="cov-gauge-sub">Branches hit / found</div>
14291          <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>
14292        </div>
14293      </div>
14294      <div class="chart-row">
14295        <div class="chart-box">
14296          <div class="chart-box-title">Line Coverage % by Language</div>
14297          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14298        </div>
14299        <div class="chart-box">
14300          <div class="chart-box-title">Coverage Tier Distribution</div>
14301          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14302        </div>
14303      </div>
14304
14305      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14306      <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>
14307      <div class="cov-file-toolbar">
14308        <div class="cov-filter-tabs" id="cov-filter-tabs">
14309          <button class="cov-tab active" data-tier="all">All</button>
14310          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14311          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
14312          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
14313          <button class="cov-tab" data-tier="high">High (≥80%)</button>
14314        </div>
14315        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14316      </div>
14317      <div style="overflow-x:auto;">
14318        <table class="data-table" id="cov-file-table">
14319          <thead><tr>
14320            <th>File</th>
14321            <th>Lang</th>
14322            <th class="num">Line %</th>
14323            <th class="num">Lines Hit / Found</th>
14324            <th class="num">Fn %</th>
14325            <th class="num">Fns Hit / Found</th>
14326          </tr></thead>
14327          <tbody id="cov-file-tbody"></tbody>
14328        </table>
14329      </div>
14330      <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>
14331      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14332    </div>
14333
14334  </div>
14335
14336  <footer class="site-footer">
14337    local code analysis - metrics, history and reports
14338    &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>
14339    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14340    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14341    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14342    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14343  </footer>
14344
14345  <script nonce="{nonce}">
14346  (function() {{
14347    // Theme
14348    var b = document.body;
14349    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14350    var tgl = document.getElementById('theme-toggle');
14351    if (tgl) tgl.addEventListener('click', function() {{
14352      var d = b.classList.toggle('dark-theme');
14353      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14354    }});
14355
14356    // Watermarks
14357    (function() {{
14358      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14359      if (!wms.length) return;
14360      var placed = [];
14361      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;}}
14362      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];}}
14363      var half=Math.floor(wms.length/2);
14364      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;}});
14365    }})();
14366
14367    // Code particles
14368    (function() {{
14369      var container = document.getElementById('code-particles');
14370      if (!container) return;
14371      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14372      for (var i = 0; i < 36; i++) {{
14373        (function(idx) {{
14374          var el = document.createElement('span');
14375          el.className = 'code-particle';
14376          el.textContent = snippets[idx % snippets.length];
14377          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14378          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14379          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14380          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';
14381          container.appendChild(el);
14382        }})(i);
14383      }}
14384    }})();
14385
14386    // Settings modal
14387    (function() {{
14388      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'}}];
14389      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);}});}}
14390      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14391      var btn=document.getElementById('settings-btn');if(!btn)return;
14392      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14393      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>';
14394      document.body.appendChild(m);
14395      var g=document.getElementById('scheme-grid');
14396      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);}});
14397      var cl=document.getElementById('settings-close');
14398      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');}});
14399      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14400      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14401    }})();
14402
14403    // Watched folder picker
14404    (function(){{
14405      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');}};
14406      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);
14407    }})();
14408    (function() {{
14409      var btn = document.getElementById('add-watched-btn');
14410      if (!btn) return;
14411      btn.addEventListener('click', function() {{
14412        fetch('/pick-directory?kind=reports')
14413          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14414          .then(function(data) {{
14415            if (!data.cancelled && data.selected_path) {{
14416              var form = document.createElement('form');
14417              form.method = 'POST';
14418              form.action = '/watched-dirs/add';
14419              var ri = document.createElement('input');
14420              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14421              var fi = document.createElement('input');
14422              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14423              form.appendChild(ri); form.appendChild(fi);
14424              document.body.appendChild(form);
14425              if (window.__scanOverlay) window.__scanOverlay();
14426              form.submit();
14427            }}
14428          }})
14429          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14430      }});
14431    }})();
14432  }})();
14433  </script>
14434
14435  <script src="/static/chart.js" nonce="{nonce}"></script>
14436  <script nonce="{nonce}">
14437  (function() {{
14438    var SCOPE_DATA = {scope_data_json};
14439    var currentRoot = '__all__';
14440    var currentSub  = '';
14441    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14442    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14443    var ALL_CHARTS = [];
14444    var currentLangTests = [];
14445    var currentTrendPts = [];
14446
14447    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();}}
14448    function fmtFull(n){{return Number(n).toLocaleString();}}
14449    function isDark(){{return document.body.classList.contains('dark-theme');}}
14450    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14451    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14452    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14453
14454    function makeDlPlugin(fmtFn, anchor) {{
14455      return {{
14456        afterDatasetsDraw: function(chart) {{
14457          var ctx = chart.ctx;
14458          var tc = txtClr();
14459          chart.data.datasets.forEach(function(ds, di) {{
14460            var meta = chart.getDatasetMeta(di);
14461            meta.data.forEach(function(el, idx) {{
14462              var label = fmtFn(ds.data[idx], di, idx);
14463              if (label == null || label === '') return;
14464              ctx.save();
14465              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14466              ctx.fillStyle = tc;
14467              if (anchor === 'top') {{
14468                ctx.textAlign = 'center';
14469                ctx.textBaseline = 'bottom';
14470                ctx.fillText(String(label), el.x, el.y - 5);
14471              }} else {{
14472                ctx.textAlign = 'left';
14473                ctx.textBaseline = 'middle';
14474                ctx.fillText(String(label), el.x + 5, el.y);
14475              }}
14476              ctx.restore();
14477            }});
14478          }});
14479        }}
14480      }};
14481    }}
14482
14483    // Cursor: pointer over chart data, default over empty chart area.
14484    function chartCursor(e, els) {{
14485      var t = e.native && e.native.target;
14486      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14487    }}
14488    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14489
14490    // ── Global bar hover emphasis ──────────────────────────────────────────────
14491    // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14492    // *other* bars does nothing when there is only one). Give every bar chart a
14493    // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14494    // animates via the fast active transition. Applied globally through a plugin so
14495    // it covers all current and future bar charts on the page.
14496    function tmLighten(c, amt) {{
14497      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14498        var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14499        r = Math.round(r + (255 - r) * amt);
14500        g = Math.round(g + (255 - g) * amt);
14501        b = Math.round(b + (255 - b) * amt);
14502        return 'rgb(' + r + ',' + g + ',' + b + ')';
14503      }}
14504      return c;
14505    }}
14506    var tmBarHoverEmphasis = {{
14507      id: 'tmBarHoverEmphasis',
14508      beforeInit: function(chart) {{
14509        if (!chart.config || chart.config.type !== 'bar') return;
14510        (chart.data.datasets || []).forEach(function(ds) {{
14511          var bg = ds.backgroundColor;
14512          if (ds.hoverBackgroundColor == null) {{
14513            ds.hoverBackgroundColor = Array.isArray(bg)
14514              ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14515              : tmLighten(bg, 0.24);
14516          }}
14517          if (ds.hoverBorderColor == null) {{
14518            ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14519          }}
14520          if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14521        }});
14522      }}
14523    }};
14524    Chart.register(tmBarHoverEmphasis);
14525    // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14526    try {{
14527      Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14528      Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14529      Chart.defaults.transitions.active.animation.duration = 260;
14530    }} catch (e) {{}}
14531
14532    // Plugin: draws % labels inside each doughnut slice.
14533    var donutPctPlugin = {{
14534      afterDatasetsDraw: function(chart) {{
14535        var ctx = chart.ctx;
14536        chart.data.datasets.forEach(function(ds, di) {{
14537          var meta = chart.getDatasetMeta(di);
14538          if (meta.hidden) return;
14539          var total = 0;
14540          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14541          if (!total) return;
14542          meta.data.forEach(function(arc, i) {{
14543            if (arc.hidden) return;
14544            var val = ds.data[i] || 0;
14545            var pct = val / total * 100;
14546            if (pct < 3) return;
14547            var midAngle = (arc.startAngle + arc.endAngle) / 2;
14548            var midR = (arc.innerRadius + arc.outerRadius) / 2;
14549            var tx = arc.x + midR * Math.cos(midAngle);
14550            var ty = arc.y + midR * Math.sin(midAngle);
14551            ctx.save();
14552            ctx.textAlign = 'center';
14553            ctx.textBaseline = 'middle';
14554            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14555            ctx.shadowColor = 'rgba(0,0,0,0.45)';
14556            ctx.shadowBlur = 3;
14557            ctx.fillStyle = '#fff';
14558            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14559            ctx.restore();
14560          }});
14561        }});
14562      }}
14563    }};
14564
14565    function makeTmOverlay(title, subtitle, h) {{
14566      var overlay = document.createElement('div');
14567      overlay.className = 'chart-modal-overlay';
14568      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14569      var ch = Math.min(h || 560, maxH);
14570      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14571      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>';
14572      document.body.appendChild(overlay);
14573      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14574      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14575      return document.getElementById('tm-modal-canvas');
14576    }}
14577
14578    function getDataset() {{
14579      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14580      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14581      return r;
14582    }}
14583    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14584
14585    function showNoData(id, show) {{
14586      var el = document.getElementById(id);
14587      if (!el) return;
14588      var wrap = el.previousElementSibling;
14589      el.style.display = show ? '' : 'none';
14590      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14591    }}
14592
14593    // Shared hover treatment for every single-series bar/doughnut chart on this page:
14594    // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14595    // treatment used by the language charts on the scan results page.
14596    function tmFadeColor(c) {{
14597      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14598      return c;
14599    }}
14600    function tmApplyFade(chart, activeIdx) {{
14601      var ds = chart.data.datasets[0];
14602      if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14603      if (activeIdx == null) {{
14604        ds.backgroundColor = ds._baseBg.slice();
14605      }} else {{
14606        ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14607          return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14608        }});
14609      }}
14610    }}
14611    function tmFadeHover(e, active, chart) {{
14612      var t = e.native && e.native.target;
14613      if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14614      var idx = active.length ? active[0].index : null;
14615      if (chart._fadeIdx === idx) return;
14616      chart._fadeIdx = idx;
14617      tmApplyFade(chart, idx);
14618      // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14619      // transition (doughnuts keep their own hoverOffset motion regardless).
14620      chart.update('active');
14621    }}
14622    // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14623    function tmDoughnutLegendHover(e, item, leg) {{
14624      var ch = leg.chart;
14625      var t = e.native && e.native.target;
14626      if (t) t.style.cursor = 'pointer';
14627      ch._fadeIdx = item.index;
14628      ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14629      ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14630      tmApplyFade(ch, item.index);
14631      ch.update();
14632    }}
14633    function tmDoughnutLegendLeave(e, item, leg) {{
14634      var ch = leg.chart;
14635      var t = e.native && e.native.target;
14636      if (t) t.style.cursor = 'default';
14637      ch._fadeIdx = null;
14638      ch.setActiveElements([]);
14639      ch.tooltip.setActiveElements([], {{}});
14640      tmApplyFade(ch, null);
14641      ch.update('none');
14642    }}
14643
14644    function renderTestCharts(D) {{
14645      currentLangTests = D || [];
14646      testsChart = destroyChart(testsChart);
14647      densityChart = destroyChart(densityChart);
14648      if (!D || !D.length) {{
14649        showNoData('no-data-tests', true);
14650        showNoData('no-data-density', true);
14651        return;
14652      }}
14653      showNoData('no-data-tests', false);
14654      showNoData('no-data-density', false);
14655      var top15 = D.slice(0, 15);
14656      var canvas1 = document.getElementById('canvas-tests');
14657      if (canvas1) {{
14658        testsChart = new Chart(canvas1, {{
14659          type: 'bar',
14660          data: {{
14661            labels: top15.map(function(d){{ return d.lang; }}),
14662            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14663          }},
14664          options: {{
14665            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14666            layout: {{ padding: {{ right: 64 }} }},
14667            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14668            scales: {{
14669              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14670              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14671            }}
14672          }},
14673          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14674        }});
14675        ALL_CHARTS.push(testsChart);
14676      }}
14677      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14678      var canvas2 = document.getElementById('canvas-density');
14679      if (canvas2) {{
14680        densityChart = new Chart(canvas2, {{
14681          type: 'bar',
14682          data: {{
14683            labels: topD.map(function(d){{ return d.lang; }}),
14684            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 }}]
14685          }},
14686          options: {{
14687            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14688            layout: {{ padding: {{ right: 64 }} }},
14689            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14690            scales: {{
14691              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14692              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14693            }}
14694          }},
14695          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14696        }});
14697        ALL_CHARTS.push(densityChart);
14698      }}
14699    }}
14700
14701    function renderAssertionsChart(D) {{
14702      assertionsChart = destroyChart(assertionsChart);
14703      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14704      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14705      var canvas = document.getElementById('canvas-assertions');
14706      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14707      showNoData('no-data-assertions', false);
14708      assertionsChart = new Chart(canvas, {{
14709        type: 'bar',
14710        data: {{
14711          labels: top15.map(function(d){{ return d.lang; }}),
14712          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14713        }},
14714        options: {{
14715          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14716          layout: {{ padding: {{ right: 64 }} }},
14717          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14718          scales: {{
14719            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14720            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14721          }}
14722        }},
14723        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14724      }});
14725      ALL_CHARTS.push(assertionsChart);
14726    }}
14727
14728    function renderSuitesChart(D) {{
14729      suitesChart = destroyChart(suitesChart);
14730      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14731      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14732      var canvas = document.getElementById('canvas-suites');
14733      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14734      showNoData('no-data-suites', false);
14735      suitesChart = new Chart(canvas, {{
14736        type: 'bar',
14737        data: {{
14738          labels: top15.map(function(d){{ return d.lang; }}),
14739          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 }}]
14740        }},
14741        options: {{
14742          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14743          layout: {{ padding: {{ right: 64 }} }},
14744          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14745          scales: {{
14746            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14747            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14748          }}
14749        }},
14750        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14751      }});
14752      ALL_CHARTS.push(suitesChart);
14753    }}
14754
14755    function renderFilesChart(totals) {{
14756      filesChart = destroyChart(filesChart);
14757      var canvas = document.getElementById('canvas-files');
14758      if (!canvas) return;
14759      var testF = totals.test_files || 0;
14760      var totalF = totals.total_files || 0;
14761      var nonTest = Math.max(0, totalF - testF);
14762      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14763      showNoData('no-data-files', false);
14764      var dark = isDark();
14765      filesChart = new Chart(canvas, {{
14766        type: 'doughnut',
14767        data: {{
14768          labels: ['Test Files', 'Non-Test Files'],
14769          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14770        }},
14771        options: {{
14772          responsive: true, maintainAspectRatio: false, cutout: '62%',
14773          onHover: tmFadeHover,
14774          plugins: {{
14775            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14776              generateLabels: function(chart) {{
14777                var ds = chart.data.datasets[0];
14778                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14779                return chart.data.labels.map(function(lbl, i) {{
14780                  var val = ds.data[i] || 0;
14781                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14782                  return {{
14783                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14784                    fillStyle: ds.backgroundColor[i],
14785                    strokeStyle: ds.borderColor,
14786                    lineWidth: ds.borderWidth,
14787                    hidden: false,
14788                    index: i,
14789                    datasetIndex: 0
14790                  }};
14791                }});
14792              }}
14793            }},
14794              onHover: tmDoughnutLegendHover,
14795              onLeave: tmDoughnutLegendLeave
14796            }},
14797            tooltip: {{ callbacks: {{ label: function(ctx) {{
14798              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14799              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14800            }} }} }}
14801          }}
14802        }},
14803        plugins: [donutPctPlugin]
14804      }});
14805      ALL_CHARTS.push(filesChart);
14806    }}
14807
14808    function renderCompositionChart(totals) {{
14809      compositionChart = destroyChart(compositionChart);
14810      var canvas = document.getElementById('canvas-composition');
14811      if (!canvas) return;
14812      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14813      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14814      showNoData('no-data-composition', false);
14815      compositionChart = new Chart(canvas, {{
14816        type: 'bar',
14817        data: {{
14818          labels: ['Test Functions', 'Assertions', 'Test Suites'],
14819          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14820        }},
14821        options: {{
14822          responsive: true, maintainAspectRatio: false,
14823          onHover: tmFadeHover,
14824          layout: {{ padding: {{ top: 22 }} }},
14825          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14826          scales: {{
14827            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14828            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14829          }}
14830        }},
14831        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14832      }});
14833      ALL_CHARTS.push(compositionChart);
14834    }}
14835
14836    function renderCovCharts(covD, tiers) {{
14837      covChart = destroyChart(covChart);
14838      tierChart = destroyChart(tierChart);
14839      var covCanvas = document.getElementById('canvas-cov');
14840      if (covCanvas && covD && covD.length) {{
14841        covChart = new Chart(covCanvas, {{
14842          type: 'bar',
14843          data: {{
14844            labels: covD.map(function(d){{ return d.lang; }}),
14845            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 }}]
14846          }},
14847          options: {{
14848            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14849            layout: {{ padding: {{ right: 52 }} }},
14850            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14851            scales: {{
14852              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14853              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14854            }}
14855          }},
14856          plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
14857        }});
14858        ALL_CHARTS.push(covChart);
14859      }}
14860      var tierCanvas = document.getElementById('canvas-cov-tiers');
14861      if (tierCanvas && tiers) {{
14862        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14863        tierChart = new Chart(tierCanvas, {{
14864          type: 'doughnut',
14865          data: {{
14866            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14867            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14868          }},
14869          options: {{
14870            responsive: true, maintainAspectRatio: false, cutout: '62%',
14871            onHover: tmFadeHover,
14872            plugins: {{
14873              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14874                onHover: tmDoughnutLegendHover,
14875                onLeave: tmDoughnutLegendLeave
14876              }},
14877              tooltip: {{ callbacks: {{ label: function(ctx) {{
14878                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14879                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14880              }} }} }}
14881            }}
14882          }},
14883          plugins: [donutPctPlugin]
14884        }});
14885        ALL_CHARTS.push(tierChart);
14886      }}
14887    }}
14888
14889    function buildLangTable(D) {{
14890      var tbody = document.getElementById('lang-tbody');
14891      if (!tbody) return;
14892      if (!D || !D.length) {{
14893        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>';
14894        return;
14895      }}
14896      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14897      tbody.innerHTML = D.map(function(d) {{
14898        var barW = Math.round(d.density / maxDensity * 120);
14899        return '<tr>' +
14900          '<td><strong>' + d.lang + '</strong></td>' +
14901          '<td class="num">' + fmtFull(d.tests) + '</td>' +
14902          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14903          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14904          '<td class="num">' + fmtFull(d.code) + '</td>' +
14905          '<td class="num">' + fmtFull(d.files) + '</td>' +
14906          '<td class="num">' + d.density.toFixed(2) + '</td>' +
14907          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14908          '</tr>';
14909      }}).join('');
14910    }}
14911
14912    var covFileData = [];
14913    var covFileTier = 'all';
14914    var covFileSearch = '';
14915
14916    function pctBadge(pct) {{
14917      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14918      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14919      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14920    }}
14921
14922    function buildCovFileTable() {{
14923      var tbody = document.getElementById('cov-file-tbody');
14924      var empty = document.getElementById('cov-file-empty');
14925      var count = document.getElementById('cov-file-count');
14926      if (!tbody) return;
14927      var srch = covFileSearch.toLowerCase();
14928      var filtered = covFileData.filter(function(f) {{
14929        if (covFileTier === 'zero' && f.line_pct > 0) return false;
14930        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14931        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14932        if (covFileTier === 'high' && f.line_pct < 80) return false;
14933        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14934        return true;
14935      }});
14936      if (!filtered.length) {{
14937        tbody.innerHTML = '';
14938        if (empty) empty.style.display = '';
14939        if (count) count.textContent = '';
14940        return;
14941      }}
14942      if (empty) empty.style.display = 'none';
14943      var shown = Math.min(filtered.length, 500);
14944      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14945      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14946        var fnCol = f.fn_pct < 0
14947          ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14948          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14949        return '<tr>' +
14950          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
14951          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14952          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14953          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14954          fnCol +
14955          '</tr>';
14956      }}).join('');
14957    }}
14958
14959    (function() {{
14960      var tabs = document.getElementById('cov-filter-tabs');
14961      if (tabs) {{
14962        tabs.addEventListener('click', function(e) {{
14963          var btn = e.target.closest('.cov-tab');
14964          if (!btn) return;
14965          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14966          btn.classList.add('active');
14967          covFileTier = btn.getAttribute('data-tier');
14968          buildCovFileTable();
14969        }});
14970      }}
14971      var srch = document.getElementById('cov-file-search');
14972      if (srch) {{
14973        srch.addEventListener('input', function() {{
14974          covFileSearch = this.value;
14975          buildCovFileTable();
14976        }});
14977      }}
14978    }})();
14979
14980    function updateCovGauges(t) {{
14981      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14982      var el;
14983      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14984      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14985      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14986      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14987      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14988      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14989    }}
14990
14991    function applyScope() {{
14992      var d = getDataset();
14993      var t = d.totals;
14994      var el;
14995      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14996      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14997      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14998      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14999      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15000      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15001      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15002      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15003      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15004      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15005      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15006      renderTestCharts(d.lang_tests);
15007      renderAssertionsChart(d.lang_tests);
15008      renderSuitesChart(d.lang_tests);
15009      renderFilesChart(t);
15010      renderCompositionChart(t);
15011      buildLangTable(d.lang_tests);
15012      var covPanel = document.getElementById('cov-panel');
15013      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15014      if (d.has_coverage) {{
15015        renderCovCharts(d.cov, d.cov_tiers);
15016        updateCovGauges(t);
15017        covFileData = d.file_cov || [];
15018        covFileTier = 'all';
15019        covFileSearch = '';
15020        var tabs = document.getElementById('cov-filter-tabs');
15021        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15022        var srch = document.getElementById('cov-file-search');
15023        if (srch) srch.value = '';
15024        buildCovFileTable();
15025      }}
15026      loadTrend();
15027    }}
15028
15029    // Populate scope-root-sel from SCOPE_DATA keys
15030    (function() {{
15031      var sel = document.getElementById('scope-root-sel');
15032      if (!sel) return;
15033      Object.keys(SCOPE_DATA).forEach(function(k) {{
15034        if (k === '__all__') return;
15035        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15036      }});
15037    }})();
15038
15039    document.getElementById('scope-root-sel').addEventListener('change', function() {{
15040      currentRoot = this.value;
15041      currentSub = '';
15042      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15043      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15044      var subWrap = document.getElementById('scope-sub-wrap');
15045      var subSel  = document.getElementById('scope-sub-sel');
15046      subSel.innerHTML = '<option value="">Entire project</option>';
15047      if (subNames.length) {{
15048        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15049        subWrap.style.display = 'flex';
15050      }} else {{
15051        subWrap.style.display = 'none';
15052      }}
15053      applyScope();
15054    }});
15055
15056    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15057      currentSub = this.value;
15058      applyScope();
15059    }});
15060
15061    var allTrendData = [];
15062
15063    var TM_Y_META = {{
15064      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15065      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
15066    }};
15067
15068    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15069    function hexRgb(hex) {{
15070      var h = String(hex).replace('#', '');
15071      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15072      var n = parseInt(h, 16);
15073      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15074    }}
15075    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15076    // tint at the top to transparent at the bottom (no flat solid block).
15077    function tmTrendGradient(ctx2, chartArea, color) {{
15078      var rgb = hexRgb(color);
15079      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15080      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
15081      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15082      g.addColorStop(1,   'rgba(' + rgb + ',0)');
15083      return g;
15084    }}
15085
15086    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15087    // so linear interpolation between adjacent points matches the drawn line).
15088    function tmLineYAt(chart, px) {{
15089      var meta = chart.getDatasetMeta(0);
15090      if (!meta || !meta.data || !meta.data.length) return null;
15091      var d = meta.data;
15092      if (px <= d[0].x) return d[0].y;
15093      for (var i = 1; i < d.length; i++) {{
15094        if (px <= d[i].x) {{
15095          var span = d[i].x - d[i - 1].x;
15096          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15097          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15098        }}
15099      }}
15100      return d[d.length - 1].y;
15101    }}
15102
15103    // Plugin: only show the tooltip / finger cursor when the pointer is over the
15104    // gradient fill (inside the plot and at/below the line) — never in the empty
15105    // space above the line. Outside the fill we retype the event as 'mouseout' so
15106    // the core interaction dismisses any active tooltip on its own.
15107    var tmFillGuard = {{
15108      id: 'tmFillGuard',
15109      beforeEvent: function(chart, args) {{
15110        var e = args.event;
15111        if (!e || e.type !== 'mousemove') return;
15112        var ca = chart.chartArea;
15113        if (!ca) return;
15114        var inFill = false;
15115        if (e.x >= ca.left && e.x <= ca.right) {{
15116          var ly = tmLineYAt(chart, e.x);
15117          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15118        }}
15119        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15120        if (!inFill) {{ e.type = 'mouseout'; }}
15121      }}
15122    }};
15123
15124    // Single source of truth for the test-metrics trend chart config so the inline
15125    // chart and the Full View modal render identically (straight segments, gradient
15126    // fill, white-ringed points, gradient-only interactivity).
15127    function buildTmTrendConfig(pts, ctrl, meta) {{
15128      return {{
15129        type: 'line',
15130        data: {{
15131          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15132          datasets: [{{
15133            label: meta.label,
15134            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15135            borderColor: meta.color,
15136            borderWidth: 2.5,
15137            backgroundColor: function(context) {{
15138              var ca = context.chart.chartArea;
15139              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15140              return tmTrendGradient(context.chart.ctx, ca, meta.color);
15141            }},
15142            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15143            pointBorderColor: '#fff',
15144            pointBorderWidth: 2,
15145            pointRadius: 6,
15146            pointHoverRadius: 9,
15147            pointHoverBorderWidth: 2.5,
15148            fill: true, tension: 0
15149          }}]
15150        }},
15151        options: {{
15152          responsive: true, maintainAspectRatio: false,
15153          layout: {{ padding: {{ top: 22 }} }},
15154          interaction: {{ mode: 'index', intersect: false }},
15155          plugins: {{
15156            legend: {{ display: false }},
15157            tooltip: {{
15158              mode: 'index', intersect: false,
15159              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15160            }}
15161          }},
15162          scales: {{
15163            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15164            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15165          }}
15166        }},
15167        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15168      }};
15169    }}
15170
15171    function getTrendControls() {{
15172      var ySel    = document.getElementById('tm-trend-y');
15173      var xSel    = document.getElementById('tm-trend-x');
15174      var sizeSel = document.getElementById('tm-trend-size');
15175      var subSel  = document.getElementById('tm-trend-sub');
15176      return {{
15177        yKey:    ySel    ? ySel.value    : 'test_count',
15178        xMode:   xSel    ? xSel.value    : 'commit',
15179        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
15180        submod:  subSel  ? subSel.value  : ''
15181      }};
15182    }}
15183
15184    function makeTrendLabel(d, xMode) {{
15185      if (xMode === 'commit') {{
15186        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15187      }}
15188      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15189    }}
15190
15191    function buildTrend(data) {{
15192      allTrendData = data || [];
15193      renderTrend();
15194    }}
15195
15196    function renderTrend() {{
15197      var data = allTrendData;
15198      var ctrl = getTrendControls();
15199      var trendCanvas = document.getElementById('canvas-trend');
15200      var trendWrap   = document.getElementById('trend-canvas-wrap');
15201      var trendEmpty  = document.getElementById('trend-empty');
15202
15203      // Apply chart size
15204      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15205
15206      // Filter by submodule if selected (entries from project_label match)
15207      var pts = data.slice().reverse();
15208      if (ctrl.submod) {{
15209        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15210      }}
15211
15212      currentTrendPts = pts;
15213
15214      if (!pts.length) {{
15215        if (trendCanvas) trendCanvas.style.display = 'none';
15216        if (trendEmpty) trendEmpty.style.display = '';
15217        return;
15218      }}
15219      if (trendCanvas) trendCanvas.style.display = '';
15220      if (trendEmpty) trendEmpty.style.display = 'none';
15221
15222      trendChart = destroyChart(trendChart);
15223      if (!trendCanvas) return;
15224
15225      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15226
15227      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15228      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15229      ALL_CHARTS.push(trendChart);
15230
15231      // Populate submodule selector from unique project_labels
15232      var subSel = document.getElementById('tm-trend-sub');
15233      var subLabel = document.getElementById('tm-sub-label');
15234      if (subSel && data.length) {{
15235        var projects = [];
15236        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15237        if (projects.length > 1) {{
15238          var curVal = subSel.value;
15239          subSel.innerHTML = '<option value="">All (project total)</option>';
15240          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15241          if (subLabel) subLabel.style.display = '';
15242        }} else {{
15243          if (subLabel) subLabel.style.display = 'none';
15244        }}
15245      }}
15246    }}
15247
15248    // ── Full View expand buttons ──────────────────────────────────────────────
15249    (function() {{
15250      var btn = document.getElementById('tests-expand-btn');
15251      if (!btn) return;
15252      btn.addEventListener('click', function() {{
15253        var D = currentLangTests;
15254        if (!D || !D.length) return;
15255        var top15 = D.slice(0, 15);
15256        var h = Math.max(320, top15.length * 36 + 80);
15257        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15258        if (!canvas) return;
15259        new Chart(canvas, {{
15260          type: 'bar',
15261          data: {{
15262            labels: top15.map(function(d){{ return d.lang; }}),
15263            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15264          }},
15265          options: {{
15266            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15267            layout: {{ padding: {{ right: 72 }} }},
15268            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15269            scales: {{
15270              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15271              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15272            }}
15273          }},
15274          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15275        }});
15276      }});
15277    }})();
15278
15279    (function() {{
15280      var btn = document.getElementById('density-expand-btn');
15281      if (!btn) return;
15282      btn.addEventListener('click', function() {{
15283        var D = currentLangTests;
15284        if (!D || !D.length) return;
15285        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15286        var h = Math.max(320, topD.length * 36 + 80);
15287        var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15288        if (!canvas) return;
15289        new Chart(canvas, {{
15290          type: 'bar',
15291          data: {{
15292            labels: topD.map(function(d){{ return d.lang; }}),
15293            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 }}]
15294          }},
15295          options: {{
15296            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15297            layout: {{ padding: {{ right: 72 }} }},
15298            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15299            scales: {{
15300              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15301              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15302            }}
15303          }},
15304          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15305        }});
15306      }});
15307    }})();
15308
15309    (function() {{
15310      var btn = document.getElementById('trend-expand-btn');
15311      if (!btn) return;
15312      btn.addEventListener('click', function() {{
15313        var pts = currentTrendPts;
15314        if (!pts || !pts.length) return;
15315        var ctrl = getTrendControls();
15316        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15317        var title = meta.label + ' Trend \u2014 Full View';
15318        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15319        if (!canvas) return;
15320        // Reuse the exact inline-chart config so Full View matches the default view
15321        // (straight segments + gradient-only interactivity), just larger.
15322        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15323      }});
15324    }})();
15325
15326    (function() {{
15327      var btn = document.getElementById('assertions-expand-btn');
15328      if (!btn) return;
15329      btn.addEventListener('click', function() {{
15330        var D = currentLangTests;
15331        if (!D || !D.length) return;
15332        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15333        if (!top15.length) return;
15334        var h = Math.max(320, top15.length * 36 + 80);
15335        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15336        if (!canvas) return;
15337        new Chart(canvas, {{
15338          type: 'bar',
15339          data: {{
15340            labels: top15.map(function(d){{ return d.lang; }}),
15341            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15342          }},
15343          options: {{
15344            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15345            layout: {{ padding: {{ right: 72 }} }},
15346            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15347            scales: {{
15348              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15349              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15350            }}
15351          }},
15352          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15353        }});
15354      }});
15355    }})();
15356
15357    (function() {{
15358      var btn = document.getElementById('suites-expand-btn');
15359      if (!btn) return;
15360      btn.addEventListener('click', function() {{
15361        var D = currentLangTests;
15362        if (!D || !D.length) return;
15363        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15364        if (!top15.length) return;
15365        var h = Math.max(320, top15.length * 36 + 80);
15366        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15367        if (!canvas) return;
15368        new Chart(canvas, {{
15369          type: 'bar',
15370          data: {{
15371            labels: top15.map(function(d){{ return d.lang; }}),
15372            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 }}]
15373          }},
15374          options: {{
15375            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15376            layout: {{ padding: {{ right: 72 }} }},
15377            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15378            scales: {{
15379              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15380              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15381            }}
15382          }},
15383          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15384        }});
15385      }});
15386    }})();
15387
15388    // Wire trend control selectors — re-render without re-fetching
15389    (function() {{
15390      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15391        var el = document.getElementById(id);
15392        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15393      }});
15394    }})();
15395
15396    function loadTrend() {{
15397      var url = '/api/metrics/history?limit=100';
15398      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15399      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15400        buildTrend(data);
15401        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15402        var btn = document.getElementById('multi-compare-trend-btn');
15403        if (btn) {{
15404          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15405          if (ids.length >= 2) {{
15406            btn.style.display = '';
15407            btn.onclick = function() {{
15408              // Reverse so oldest first (API returns newest first).
15409              var sorted = ids.slice().reverse();
15410              if (sorted.length === 2) {{
15411                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15412              }} else {{
15413                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15414              }}
15415            }};
15416          }} else {{
15417            btn.style.display = 'none';
15418          }}
15419        }}
15420      }}).catch(function(){{
15421        var trendEmpty = document.getElementById('trend-empty');
15422        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15423      }});
15424    }}
15425
15426    // Re-render charts on theme toggle
15427    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15428      setTimeout(function() {{
15429        ALL_CHARTS.forEach(function(c) {{
15430          if (c && c.options && c.options.scales) {{
15431            Object.values(c.options.scales).forEach(function(ax) {{
15432              if (ax.grid) ax.grid.color = clr();
15433              if (ax.ticks) ax.ticks.color = txtClr();
15434            }});
15435            c.update();
15436          }}
15437        }});
15438      }}, 80);
15439    }});
15440
15441    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15442    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15443    function tmExportMeta() {{
15444      var sel = document.getElementById('scope-sel');
15445      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15446      if (!proj || proj === '__all__') proj = 'All projects';
15447      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15448      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15449      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15450      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15451      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15452    }}
15453
15454    function exportTmXLSX() {{
15455      var D = currentLangTests;
15456      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15457      var t = tmExportMeta();
15458      function s2b(s) {{ return new TextEncoder().encode(s); }}
15459      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
15460      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; }}
15461      function crc32(d) {{
15462        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;}}}}
15463        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15464      }}
15465      // Store all cells as strings so Excel left-aligns uniformly.
15466      function cs(addr, val, bold) {{
15467        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15468      }}
15469      // Build an Excel Table XML definition for a given sheet range and columns.
15470      function makeTableXml(tblId, name, ref, cols) {{
15471        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15472        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15473        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15474        x+='<autoFilter ref="'+ref+'"/>';
15475        x+='<tableColumns count="'+cols.length+'">';
15476        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15477        x+='</tableColumns>';
15478        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15479        return x+'</table>';
15480      }}
15481      // Worksheet XML with optional Excel Table part reference.
15482      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15483        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15484        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15485        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15486        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15487        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15488        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>';}});
15489        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>';}}
15490        x+='</sheetData>';
15491        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15492        return x+'</worksheet>';
15493      }}
15494
15495      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15496      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15497      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15498      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15499      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15500      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15501
15502      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15503      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15504      var sheets=[];
15505
15506      // Sheet: Summary
15507      var sumHdr=['Metric','Value'];
15508      var sumRows=[
15509        ['Project / Scope', t.proj],
15510        ['Export Date', t.full],
15511        ['Test Functions', Number(totTests).toLocaleString()],
15512        ['Assertions', Number(totAssert).toLocaleString()],
15513        ['Test Suites', Number(totSuites).toLocaleString()],
15514        ['Languages with Tests', String(D.length)],
15515        ['Total Code Lines', Number(totCode).toLocaleString()],
15516        ['Average Density (per 1K)', String(avgDensity)],
15517      ];
15518      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15519
15520      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15521      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15522      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)];}});
15523      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15524      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15525
15526      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15527      var covDs=(typeof getDataset==='function')?getDataset():null;
15528      if(covDs&&covDs.has_coverage){{
15529        var covT=covDs.totals||{{}};
15530        var covSumHdr=['Metric','Value'];
15531        var covSumRows=[
15532          ['Line Coverage', (covT.cov_line||'0')+'%'],
15533          ['Function Coverage', (covT.cov_fn||'0')+'%'],
15534          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15535        ];
15536        if(covDs.cov_tiers){{
15537          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15538          covSumRows.push(['Files Moderate (50–79%)', String(covDs.cov_tiers.mid||0)]);
15539          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15540        }}
15541        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15542
15543        if(covDs.cov&&covDs.cov.length){{
15544          var covLangHdr=['Language','Line Coverage %'];
15545          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15546          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15547        }}
15548        if(covFileData&&covFileData.length){{
15549          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15550          var covFileRows=covFileData.map(function(f){{
15551            var noFn=f.fn_pct<0;
15552            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)];
15553          }});
15554          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15555        }}
15556      }}
15557
15558      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>';
15559      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>';
15560
15561      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15562      var files=[];
15563      var ctOverrides='', wbSheetTags='', wbRelTags='';
15564      sheets.forEach(function(sh,i){{
15565        var n=i+1;
15566        var lastCol=col2l(sh.hdr.length);
15567        var ref='A1:'+lastCol+(sh.rows.length+1);
15568        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15569        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15570        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>';
15571        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15572        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15573        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15574        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15575        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15576        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15577        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15578      }});
15579      var styleRid='rId'+(sheets.length+1);
15580      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>';
15581      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>';
15582      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>';
15583      files.unshift(
15584        {{name:'[Content_Types].xml',data:s2b(ct)}},
15585        {{name:'_rels/.rels',data:s2b(dotrels)}},
15586        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15587        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15588        {{name:'xl/styles.xml',data:s2b(styl)}}
15589      );
15590      var parts=[],offsets=[],total=0;
15591      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;}});
15592      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;}});
15593      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));
15594      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;}});
15595      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15596      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15597      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15598      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15599    }}
15600
15601    function exportTmPNG() {{
15602      // Map canvas IDs to display titles
15603      var CHART_TITLES = {{
15604        'canvas-trend':       'TEST COUNT TREND',
15605        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
15606        'canvas-density':     'TEST DENSITY (per 1,000 code lines)',
15607        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
15608        'canvas-suites':      'TEST SUITES BY LANGUAGE',
15609        'canvas-files':       'TEST FILES BREAKDOWN',
15610        'canvas-composition': 'TEST COMPOSITION',
15611        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
15612        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
15613      }};
15614      // Coverage canvases are only appended when the LCOV panel is visible (has data).
15615      var covPanelEl=document.getElementById('cov-panel');
15616      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15617      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15618      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15619      // Include only charts that actually rendered data. A "no data" chart has its
15620      // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15621      // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15622      function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15623      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15624      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15625      var t=tmExportMeta();
15626      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15627      var trendCanvas=document.getElementById('canvas-trend');
15628      var hasTrend=chartHasData(trendCanvas);
15629      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15630      var TOTAL_W=COLW*2+GAP;
15631      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15632      TREND_H=Math.min(Math.max(200,TREND_H),340);
15633      // Per-row chart heights (2-col grid)
15634      var gridRows=Math.ceil(gridCanvases.length/2);
15635      var rowHeights=[];
15636      for(var ri=0;ri<gridRows;ri++){{
15637        var rh=240;
15638        for(var ci=0;ci<2;ci++){{
15639          var cv=gridCanvases[ri*2+ci];
15640          if(cv&&cv.width>0){{
15641            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15642            rh=Math.max(rh,Math.min(420,nat));
15643          }}
15644        }}
15645        rowHeights.push(rh);
15646      }}
15647      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15648      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15649      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15650      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15651      var ctx=out.getContext('2d');
15652      var cs2=getComputedStyle(document.body);
15653      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15654      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15655      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15656
15657      // Background
15658      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15659
15660      // Orange header block
15661      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15662      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15663      ctx.fillText('Test Metrics — '+t.proj,22,42);
15664      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15665      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
15666      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15667
15668      // Helper: draw a section title label
15669      function drawTitle(label, x, y, w) {{
15670        ctx.save();
15671        ctx.fillStyle=oxide;
15672        ctx.font='700 11px '+TM_FONT;
15673        ctx.textBaseline='middle';
15674        ctx.textAlign='left';
15675        ctx.letterSpacing='0.07em';
15676        ctx.fillText(label, x+2, y+TITLE_H/2);
15677        // Underline
15678        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15679        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15680        ctx.globalAlpha=1;
15681        ctx.restore();
15682      }}
15683
15684      var yOff=HEADER_H;
15685
15686      // Trend chart (full width)
15687      if(hasTrend){{
15688        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15689        yOff+=TITLE_H;
15690        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15691        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15692        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15693        ctx.drawImage(surf,0,yOff);
15694        yOff+=TREND_H+ROW_PAD;
15695      }}
15696
15697      // Grid charts (2-col), each cell gets title + chart
15698      for(var gi=0;gi<gridRows;gi++){{
15699        var rh2=rowHeights[gi];
15700        // Draw row titles and charts
15701        for(var gci=0;gci<2;gci++){{
15702          var idx2=gi*2+gci;
15703          if(idx2>=gridCanvases.length)continue;
15704          var gcv=gridCanvases[idx2];
15705          var gx=gci*(COLW+GAP);
15706          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15707        }}
15708        yOff+=TITLE_H;
15709        for(var gci2=0;gci2<2;gci2++){{
15710          var idx3=gi*2+gci2;
15711          if(idx3>=gridCanvases.length)continue;
15712          var gcv2=gridCanvases[idx3];
15713          var gx2=gci2*(COLW+GAP);
15714          var natW=gcv2.width,natH=gcv2.height;
15715          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15716          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15717          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15718          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15719          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15720          ctx.drawImage(surf2,gx2,yOff);
15721        }}
15722        yOff+=rh2+ROW_PAD;
15723      }}
15724
15725      // Dark footer
15726      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15727      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15728      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15729
15730      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15731      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15732    }}
15733
15734    function exportTmPDF(ev) {{
15735      var D=currentLangTests;
15736      var t=tmExportMeta();
15737      var strips=document.querySelectorAll('.summary-strip');
15738      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15739      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15740      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15741      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15742      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15743      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15744      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15745      var rows='';
15746      (D||[]).forEach(function(d){{
15747        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15748          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15749          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15750          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15751          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15752          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15753          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15754      }});
15755      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15756        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15757        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15758        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15759        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15760        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15761        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15762      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>';
15763      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15764        +'html,body{{height:100%;margin:0;}}'
15765        +'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;}}'
15766        +'.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;}}'
15767        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15768        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15769        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15770        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15771        +'.rep-body{{padding:20px 32px;flex:1;}}'
15772        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15773        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15774        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15775        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15776        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15777        +'.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;}}'
15778        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15779        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15780        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15781        +'.n{{text-align:right;}}'
15782        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15783        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15784        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15785        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15786        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15787        +'.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;}}'
15788        +'</style>';
15789      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15790      var covDs=(typeof getDataset==='function')?getDataset():null;
15791      var covHtml='';
15792      if(covDs&&covDs.has_coverage){{
15793        var covT=covDs.totals||{{}};
15794        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15795          +'<div class="cov-strip">'
15796          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15797          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15798          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15799          +'</div>';
15800        if(covFileData&&covFileData.length){{
15801          var cfrows='';
15802          covFileData.forEach(function(f){{
15803            var noFn=f.fn_pct<0;
15804            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15805              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15806              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15807              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15808              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15809          }});
15810          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15811            +'<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>';
15812        }}
15813      }}
15814      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15815        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
15816        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15817        +'<div class="rep-body">'+statsHtml
15818        +'<div class="section-hdr">Language Breakdown</div>'
15819        +tableHtml+covHtml+'</div>'
15820        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15821        +'</body></html>';
15822      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15823      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15824      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15825    }}
15826
15827    (function() {{
15828      // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
15829      // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
15830      var xBtn=document.getElementById('tm-export-xlsx-btn');
15831      var pngBtn=document.getElementById('tm-export-png-btn');
15832      var pdfBtn=document.getElementById('tm-export-pdf-btn');
15833      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15834      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15835      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15836    }})();
15837
15838    applyScope();
15839  }})();
15840  </script>
15841  <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>
15842  {toast_assets}
15843</body>
15844</html>"#,
15845    );
15846    (
15847        [(axum::http::header::CACHE_CONTROL, "no-store")],
15848        Html(html),
15849    )
15850        .into_response()
15851}
15852
15853// ── Embeddable widget ─────────────────────────────────────────────────────────
15854// Protected. Returns a self-contained HTML page suitable for iframing inside
15855// Jenkins build summaries, Confluence iframe macros, or Jira panels.
15856//
15857// GET /embed/summary?run_id=<uuid>&theme=dark
15858
15859#[derive(Deserialize)]
15860struct EmbedQuery {
15861    run_id: Option<String>,
15862    theme: Option<String>,
15863}
15864
15865async fn embed_handler(
15866    State(state): State<AppState>,
15867    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15868    Query(query): Query<EmbedQuery>,
15869) -> Response {
15870    let entry = {
15871        let reg = state.registry.lock().await;
15872        query.run_id.as_ref().map_or_else(
15873            || reg.entries.first().cloned(),
15874            |id| reg.find_by_run_id(id).cloned(),
15875        )
15876    };
15877
15878    let Some(entry) = entry else {
15879        return Html(
15880            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15881                .to_string(),
15882        )
15883        .into_response();
15884    };
15885
15886    let dark = query.theme.as_deref() == Some("dark");
15887    let languages: Vec<(String, u64, u64)> = entry
15888        .json_path
15889        .as_ref()
15890        .and_then(|p| read_json(p).ok())
15891        .map(|run| {
15892            run.totals_by_language
15893                .iter()
15894                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15895                .collect()
15896        })
15897        .unwrap_or_default();
15898
15899    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15900}
15901
15902fn render_embed_widget(
15903    entry: &RegistryEntry,
15904    languages: &[(String, u64, u64)],
15905    dark: bool,
15906    csp_nonce: &str,
15907) -> String {
15908    let s = &entry.summary;
15909    let total = s.code_lines + s.comment_lines + s.blank_lines;
15910    let code_pct = s
15911        .code_lines
15912        .checked_mul(100)
15913        .and_then(|n| n.checked_div(total))
15914        .unwrap_or(0);
15915
15916    let (bg, fg, surface, muted, border) = if dark {
15917        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15918    } else {
15919        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15920    };
15921
15922    let mut lang_rows = String::new();
15923    for (name, files, code) in languages {
15924        write!(
15925            lang_rows,
15926            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15927            escape_html(name),
15928            format_number(*files),
15929            format_number(*code),
15930        )
15931        .ok();
15932    }
15933
15934    let lang_table = if lang_rows.is_empty() {
15935        String::new()
15936    } else {
15937        format!(
15938            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15939        )
15940    };
15941
15942    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15943    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15944    let project_esc = escape_html(&entry.project_label);
15945    let code_lines = format_number(s.code_lines);
15946    let comment_lines = format_number(s.comment_lines);
15947    let files = format_number(s.files_analyzed);
15948    let code_raw = s.code_lines;
15949    let comment_raw = s.comment_lines;
15950    let blank_raw = s.blank_lines;
15951
15952    format!(
15953        r#"<!doctype html>
15954<html lang="en">
15955<head>
15956  <meta charset="utf-8">
15957  <meta name="viewport" content="width=device-width,initial-scale=1">
15958  <title>OxideSLOC &mdash; {project_esc}</title>
15959  <script src="/static/chart.js"></script>
15960  <style nonce="{csp_nonce}">
15961    *{{box-sizing:border-box;margin:0;padding:0}}
15962    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15963    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15964    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15965    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15966    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15967    .card .v{{font-size:18px;font-weight:700}}
15968    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15969    .row{{display:flex;gap:12px;align-items:flex-start}}
15970    .pie{{width:120px;height:120px;flex-shrink:0}}
15971    .lt{{border-collapse:collapse;width:100%;flex:1}}
15972    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15973    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15974    .n{{text-align:right}}
15975    .footer{{margin-top:10px;color:{muted};font-size:10px}}
15976  </style>
15977</head>
15978<body>
15979  <h2>{project_esc}</h2>
15980  <div class="sub">{timestamp} &middot; run {run_short}</div>
15981  <div class="cards">
15982    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15983    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15984    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15985    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15986  </div>
15987  <div class="row">
15988    <canvas class="pie" id="c"></canvas>
15989    {lang_table}
15990  </div>
15991  <div class="footer">oxide-sloc</div>
15992  <script nonce="{csp_nonce}">
15993    new Chart(document.getElementById('c'),{{
15994      type:'doughnut',
15995      data:{{
15996        labels:['Code','Comments','Blank'],
15997        datasets:[{{
15998          data:[{code_raw},{comment_raw},{blank_raw}],
15999          backgroundColor:['#4a78ee','#b35428','#aaa'],
16000          borderWidth:0
16001        }}]
16002      }},
16003      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16004    }});
16005  </script>
16006</body>
16007</html>"#
16008    )
16009}
16010
16011/// Returns a process-wide mutex unique to `dir`, so that two requests writing
16012/// artifacts into the *same* output directory (e.g. re-ingesting an identical
16013/// `run_id`) serialize instead of corrupting each other's files. Directories that
16014/// differ never contend, so legitimate parallel analyses keep their throughput.
16015fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16016    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16017        OnceLock::new();
16018    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16019    let mut guard = map
16020        .lock()
16021        .unwrap_or_else(std::sync::PoisonError::into_inner);
16022    guard
16023        .entry(dir.to_path_buf())
16024        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16025        .clone()
16026}
16027
16028#[allow(clippy::too_many_lines)]
16029fn persist_run_artifacts(
16030    run: &sloc_core::AnalysisRun,
16031    report_html: &str,
16032    run_dir: &Path,
16033    report_title: &str,
16034    file_stem: &str,
16035    result_context: RunResultContext,
16036) -> Result<(RunArtifacts, PendingPdf)> {
16037    // Serialize concurrent writers targeting this same output directory so their
16038    // file writes cannot interleave and corrupt one another.
16039    let dir_lock = output_dir_lock(run_dir);
16040    let _dir_guard = dir_lock
16041        .lock()
16042        .unwrap_or_else(std::sync::PoisonError::into_inner);
16043
16044    // Root dir + organised subdirectories.
16045    let html_dir = run_dir.join("html");
16046    let pdf_dir = run_dir.join("pdf");
16047    let excel_dir = run_dir.join("excel");
16048    let json_dir = run_dir.join("json");
16049    let submodules_dir = run_dir.join("submodules");
16050    for dir in &[
16051        run_dir,
16052        &html_dir,
16053        &pdf_dir,
16054        &excel_dir,
16055        &json_dir,
16056        &submodules_dir,
16057    ] {
16058        fs::create_dir_all(dir)
16059            .with_context(|| format!("failed to create directory {}", dir.display()))?;
16060    }
16061
16062    // HTML report in html/.
16063    let html_path = {
16064        let path = html_dir.join(format!("report_{file_stem}.html"));
16065        fs::write(&path, report_html)
16066            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16067        Some(path)
16068    };
16069
16070    // JSON result in json/.
16071    let json_path = {
16072        let path = json_dir.join(format!("result_{file_stem}.json"));
16073        let json = serde_json::to_string_pretty(run)
16074            .context("failed to serialize analysis run to JSON")?;
16075        fs::write(&path, json)
16076            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16077        Some(path)
16078    };
16079
16080    // PDF in pdf/.
16081    let (pdf_path, pending_pdf) = {
16082        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16083        match write_pdf_from_run(run, &pdf_dest) {
16084            Ok(()) => {
16085                eprintln!(
16086                    "[oxide-sloc][pdf] native PDF written to {}",
16087                    pdf_dest.display()
16088                );
16089                (Some(pdf_dest), None)
16090            }
16091            Err(native_err) => {
16092                eprintln!(
16093                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16094                );
16095                let source_html_path = html_path
16096                    .as_ref()
16097                    .expect("html_path always Some here")
16098                    .clone();
16099                let pending = Some((source_html_path, pdf_dest.clone(), false));
16100                (Some(pdf_dest), pending)
16101            }
16102        }
16103    };
16104
16105    // CSV and XLSX in excel/.
16106    let csv_path = {
16107        let path = excel_dir.join(format!("report_{file_stem}.csv"));
16108        if let Err(e) = sloc_report::write_csv(run, &path) {
16109            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16110            None
16111        } else {
16112            Some(path)
16113        }
16114    };
16115
16116    let xlsx_path = {
16117        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16118        if let Err(e) = sloc_report::write_xlsx(run, &path) {
16119            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16120            None
16121        } else {
16122            Some(path)
16123        }
16124    };
16125
16126    // Scan config in json/.
16127    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16128
16129    // Eagerly generate sub-reports before index.html so relative links work.
16130    if run.effective_configuration.discovery.submodule_breakdown {
16131        let run_id = &run.tool.run_id;
16132        for s in &run.submodule_summaries {
16133            build_submodule_row(s, run, run_id, run_dir);
16134        }
16135    }
16136
16137    // index.html at root — offline static export of the result-page dashboard.
16138    generate_offline_index(
16139        run,
16140        run_dir,
16141        file_stem,
16142        html_path.as_deref(),
16143        pdf_path.as_deref(),
16144        json_path.as_deref(),
16145        scan_config_path.as_deref(),
16146        &result_context,
16147    );
16148
16149    Ok((
16150        RunArtifacts {
16151            output_dir: run_dir.to_path_buf(),
16152            html_path,
16153            pdf_path,
16154            json_path,
16155            csv_path,
16156            xlsx_path,
16157            scan_config_path,
16158            report_title: report_title.to_string(),
16159            result_context,
16160        },
16161        pending_pdf,
16162    ))
16163}
16164
16165/// Render a static offline result-page dashboard and write it as `index.html` at
16166/// the root of the run output directory so business users can open it from disk.
16167#[allow(clippy::too_many_arguments)]
16168#[allow(clippy::too_many_lines)]
16169#[allow(clippy::similar_names)]
16170fn generate_offline_index(
16171    run: &sloc_core::AnalysisRun,
16172    run_dir: &Path,
16173    file_stem: &str,
16174    html_path: Option<&Path>,
16175    pdf_path: Option<&Path>,
16176    json_path: Option<&Path>,
16177    scan_config_path: Option<&Path>,
16178    result_context: &RunResultContext,
16179) {
16180    let prev_entry = &result_context.prev_entry;
16181    let prev_scan_count = result_context.prev_scan_count;
16182    let project_path = &result_context.project_path;
16183
16184    let scan_delta = prev_entry.as_ref().and_then(|prev| {
16185        prev.json_path
16186            .as_ref()
16187            .and_then(|p| read_json(p).ok())
16188            .map(|prev_run| compute_delta(&prev_run, run))
16189    });
16190
16191    let files_analyzed = run.per_file_records.len() as u64;
16192    let files_skipped = run.skipped_file_records.len() as u64;
16193    let totals = sum_lang_totals(run);
16194
16195    let DeltaFields {
16196        prev_fa_str,
16197        prev_fs_str,
16198        prev_pl_str,
16199        prev_cl_str,
16200        prev_cml_str,
16201        prev_bl_str,
16202        delta_fa_str,
16203        delta_fa_class,
16204        delta_fs_str,
16205        delta_fs_class,
16206        delta_pl_str,
16207        delta_pl_class,
16208        delta_cl_str,
16209        delta_cl_class,
16210        delta_cml_str,
16211        delta_cml_class,
16212        delta_bl_str,
16213        delta_bl_class,
16214        delta_lines_added,
16215        delta_lines_removed,
16216        delta_lines_net_str,
16217        delta_lines_net_class,
16218    } = compute_delta_fields(
16219        prev_entry.as_ref(),
16220        &totals,
16221        files_analyzed,
16222        files_skipped,
16223        scan_delta.as_ref(),
16224    );
16225
16226    let git_commit_url = git_commit_url_for(run);
16227    let git_branch_url = git_branch_url_for(run);
16228    let scan_performed_by = scan_performed_by(run);
16229
16230    // Convert absolute path to relative from run_dir (for file:// navigation).
16231    let make_rel = |p: Option<&Path>| -> Option<String> {
16232        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16233            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16234    };
16235
16236    let run_id = &run.tool.run_id;
16237
16238    // Submodule rows with relative paths into submodules/.
16239    let submodule_rows: Vec<SubmoduleRow> = run
16240        .submodule_summaries
16241        .iter()
16242        .map(|s| {
16243            let safe = sanitize_project_label(&s.name);
16244            let key = format!("sub_{safe}");
16245            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16246            SubmoduleRow {
16247                name: s.name.clone(),
16248                relative_path: s.relative_path.clone(),
16249                files_analyzed: s.files_analyzed,
16250                code_lines: s.code_lines,
16251                comment_lines: s.comment_lines,
16252                blank_lines: s.blank_lines,
16253                total_physical_lines: s.total_physical_lines,
16254                html_url: if sub_path.exists() {
16255                    Some(format!("submodules/{key}.html"))
16256                } else {
16257                    None
16258                },
16259            }
16260        })
16261        .collect();
16262
16263    let lang_chart_json = build_lang_chart_json(run);
16264
16265    let scan_config_rel =
16266        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16267
16268    let template = ResultTemplate {
16269        version: env!("CARGO_PKG_VERSION"),
16270        report_title: run.effective_configuration.reporting.report_title.clone(),
16271        project_path: project_path.clone(),
16272        output_dir: display_path(run_dir),
16273        run_id: run_id.clone(),
16274        run_id_short: run_id
16275            .split('-')
16276            .next_back()
16277            .unwrap_or(run_id)
16278            .chars()
16279            .take(7)
16280            .collect(),
16281        files_analyzed,
16282        files_skipped,
16283        physical_lines: totals.physical_lines,
16284        code_lines: totals.code_lines,
16285        comment_lines: totals.comment_lines,
16286        blank_lines: totals.blank_lines,
16287        mixed_lines: totals.mixed_lines,
16288        functions: totals.functions,
16289        classes: totals.classes,
16290        variables: totals.variables,
16291        imports: totals.imports,
16292        html_url: make_rel(html_path),
16293        pdf_url: make_rel(pdf_path),
16294        json_url: make_rel(json_path),
16295        html_download_url: make_rel(html_path),
16296        pdf_download_url: make_rel(pdf_path),
16297        json_download_url: make_rel(json_path),
16298        html_path: html_path.map(display_path),
16299        json_path: json_path.map(display_path),
16300        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16301        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16302        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16303        prev_fa_str,
16304        prev_fs_str,
16305        prev_pl_str,
16306        prev_cl_str,
16307        prev_cml_str,
16308        prev_bl_str,
16309        delta_fa_str,
16310        delta_fa_class,
16311        delta_fs_str,
16312        delta_fs_class,
16313        delta_pl_str,
16314        delta_pl_class,
16315        delta_cl_str,
16316        delta_cl_class,
16317        delta_cml_str,
16318        delta_cml_class,
16319        delta_bl_str,
16320        delta_bl_class,
16321        delta_lines_added,
16322        delta_lines_removed,
16323        delta_lines_net_str,
16324        delta_lines_net_class,
16325        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16326        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16327        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16328        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16329        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16330        git_branch: run.git_branch.clone(),
16331        git_branch_url,
16332        git_commit: run.git_commit_short.clone(),
16333        git_commit_long: run.git_commit_long.clone(),
16334        git_author: run.git_commit_author.clone(),
16335        git_commit_url,
16336        scan_performed_by,
16337        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16338        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16339        os_display: format!(
16340            "{} / {}",
16341            run.environment.operating_system, run.environment.architecture
16342        ),
16343        test_count: run.summary_totals.test_count,
16344        test_assertion_count: run.summary_totals.test_assertion_count,
16345        current_scan_number: prev_scan_count + 1,
16346        prev_scan_count,
16347        submodule_rows,
16348        pdf_generating: false,
16349        scan_config_url: scan_config_rel,
16350        lang_chart_json,
16351        scatter_chart_json: build_scatter_chart_json(run),
16352        semantic_chart_json: build_semantic_chart_json(run),
16353        submodule_chart_json: build_submodule_chart_json(run),
16354        has_submodule_data: !run.submodule_summaries.is_empty(),
16355        has_semantic_data: run
16356            .totals_by_language
16357            .iter()
16358            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16359        csp_nonce: String::new(),
16360        confluence_configured: false,
16361        server_mode: false,
16362        report_header_footer: run
16363            .effective_configuration
16364            .reporting
16365            .report_header_footer
16366            .clone(),
16367        is_offline: true,
16368        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16369        lsloc: run.summary_totals.lsloc,
16370        uloc: run.uloc,
16371        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16372        duplicate_group_count: run.duplicate_groups.len(),
16373        has_cocomo: run.cocomo.is_some(),
16374        cocomo_effort_str: run
16375            .cocomo
16376            .as_ref()
16377            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16378        cocomo_duration_str: run
16379            .cocomo
16380            .as_ref()
16381            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16382        cocomo_staff_str: run
16383            .cocomo
16384            .as_ref()
16385            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16386        cocomo_ksloc_str: run
16387            .cocomo
16388            .as_ref()
16389            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16390        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16391            || "Organic".to_string(),
16392            |c| cocomo_mode_label(c.mode).to_string(),
16393        ),
16394        cocomo_mode_tooltip: run
16395            .cocomo
16396            .as_ref()
16397            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16398        complexity_alert: 0,
16399        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16400        cov_line_pct: cov_pct_str(
16401            run.summary_totals.coverage_lines_hit,
16402            run.summary_totals.coverage_lines_found,
16403        ),
16404        cov_fn_pct: cov_pct_str(
16405            run.summary_totals.coverage_functions_hit,
16406            run.summary_totals.coverage_functions_found,
16407        ),
16408        cov_branch_pct: cov_pct_str(
16409            run.summary_totals.coverage_branches_hit,
16410            run.summary_totals.coverage_branches_found,
16411        ),
16412        cov_lines_summary: cov_lines_summary_str(
16413            run.summary_totals.coverage_lines_hit,
16414            run.summary_totals.coverage_lines_found,
16415        ),
16416    };
16417
16418    if let Ok(html) = template.render() {
16419        // Inline the brand + watermark logos as data URIs: a file:// page has no
16420        // server to resolve the /images/logo/* routes, so without this the top-left
16421        // logo and the repeated "Oxide" background watermark render as broken images.
16422        let html = inline_offline_logos(&html);
16423        let index_path = run_dir.join("index.html");
16424        if let Err(e) = fs::write(&index_path, html) {
16425            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16426        }
16427    }
16428}
16429
16430/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
16431/// offline `index.html` displays the brand logo and background watermark when
16432/// opened directly from disk (file://), where the `/images/...` routes do not exist.
16433fn inline_offline_logos(html: &str) -> String {
16434    use base64::Engine;
16435    let text_uri = format!(
16436        "data:image/png;base64,{}",
16437        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16438    );
16439    let small_uri = format!(
16440        "data:image/png;base64,{}",
16441        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16442    );
16443    html.replace("/images/logo/logo-text.png", &text_uri)
16444        .replace("/images/logo/small-logo.png", &small_uri)
16445}
16446
16447/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
16448/// then root (old flat layout), for backwards compatibility.
16449fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16450    // New layout: json/scan-config_*.json
16451    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16452        return Some(found);
16453    }
16454    // Old flat layout: scan-config.json or scan-config_*.json at root
16455    find_scan_config_in_dir_flat(dir)
16456}
16457
16458fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16459    let exact = dir.join("scan-config.json");
16460    if exact.exists() {
16461        return Some(exact);
16462    }
16463    fs::read_dir(dir).ok().and_then(|entries| {
16464        entries
16465            .filter_map(std::result::Result::ok)
16466            .find(|e| {
16467                let name = e.file_name();
16468                let name = name.to_string_lossy();
16469                name.starts_with("scan-config") && name.ends_with(".json")
16470            })
16471            .map(|e| e.path())
16472    })
16473}
16474
16475// ── Config export / import ────────────────────────────────────────────────────
16476
16477/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
16478/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
16479#[derive(Deserialize)]
16480struct ExportPdfRequest {
16481    html: String,
16482    #[serde(default)]
16483    filename: Option<String>,
16484}
16485
16486async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16487    let html_content = body.html;
16488    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16489    if html_content.is_empty() {
16490        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16491    }
16492    // Write HTML to a temp file, run headless Chrome PDF export, read result.
16493    let tmp_dir = std::env::temp_dir();
16494    let html_path = tmp_dir.join(format!(
16495        "sloc-export-{}.html",
16496        uuid::Uuid::new_v4().simple()
16497    ));
16498    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16499    if let Err(e) = std::fs::write(&html_path, &html_content) {
16500        return (
16501            StatusCode::INTERNAL_SERVER_ERROR,
16502            format!("Failed to write temp HTML: {e}"),
16503        )
16504            .into_response();
16505    }
16506    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16507    let _ = std::fs::remove_file(&html_path);
16508    if let Err(e) = pdf_result {
16509        let _ = std::fs::remove_file(&pdf_path);
16510        return (
16511            StatusCode::INTERNAL_SERVER_ERROR,
16512            format!("PDF generation failed: {e}"),
16513        )
16514            .into_response();
16515    }
16516    let pdf_bytes = match std::fs::read(&pdf_path) {
16517        Ok(b) => b,
16518        Err(e) => {
16519            let _ = std::fs::remove_file(&pdf_path);
16520            return (
16521                StatusCode::INTERNAL_SERVER_ERROR,
16522                format!("Failed to read PDF: {e}"),
16523            )
16524                .into_response();
16525        }
16526    };
16527    let _ = std::fs::remove_file(&pdf_path);
16528    let safe_name: String = filename
16529        .chars()
16530        .map(|c| {
16531            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16532                c
16533            } else {
16534                '_'
16535            }
16536        })
16537        .collect();
16538    let disposition = format!("attachment; filename=\"{safe_name}\"");
16539    (
16540        [
16541            (header::CONTENT_TYPE, "application/pdf".to_string()),
16542            (header::CONTENT_DISPOSITION, disposition),
16543        ],
16544        pdf_bytes,
16545    )
16546        .into_response()
16547}
16548
16549async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16550    let toml_str = match toml::to_string_pretty(&state.base_config) {
16551        Ok(s) => s,
16552        Err(e) => {
16553            return (
16554                StatusCode::INTERNAL_SERVER_ERROR,
16555                format!("serialization error: {e}"),
16556            )
16557                .into_response();
16558        }
16559    };
16560    (
16561        [
16562            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16563            (
16564                header::CONTENT_DISPOSITION,
16565                "attachment; filename=\".oxide-sloc.toml\"",
16566            ),
16567        ],
16568        toml_str,
16569    )
16570        .into_response()
16571}
16572
16573#[derive(Serialize)]
16574struct OkResponse {
16575    ok: bool,
16576}
16577
16578#[derive(Serialize)]
16579struct SaveProfileResponse {
16580    ok: bool,
16581    id: String,
16582}
16583
16584#[derive(Serialize)]
16585struct ProfileListResponse {
16586    profiles: Vec<ScanProfile>,
16587}
16588
16589#[derive(Serialize)]
16590struct ImportConfigResponse {
16591    ok: bool,
16592    config: sloc_config::AppConfig,
16593}
16594
16595#[derive(Deserialize)]
16596struct ImportConfigBody {
16597    toml: String,
16598}
16599
16600async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16601    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16602        Ok(config) => {
16603            if let Err(e) = config.validate() {
16604                return error::unprocessable_entity(&e.to_string());
16605            }
16606            Json(ImportConfigResponse { ok: true, config }).into_response()
16607        }
16608        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16609    }
16610}
16611
16612// ── Scan profiles API ─────────────────────────────────────────────────────────
16613
16614async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16615    let store = state.scan_profiles.lock().await;
16616    Json(ProfileListResponse {
16617        profiles: store.profiles.clone(),
16618    })
16619}
16620
16621#[derive(Deserialize)]
16622struct SaveScanProfileBody {
16623    name: String,
16624    params: serde_json::Value,
16625}
16626
16627async fn api_save_scan_profile(
16628    State(state): State<AppState>,
16629    Json(body): Json<SaveScanProfileBody>,
16630) -> impl IntoResponse {
16631    if body.name.trim().is_empty() {
16632        return error::bad_request("name must not be empty");
16633    }
16634
16635    let id = uuid::Uuid::new_v4().to_string();
16636    let profile = ScanProfile {
16637        id: id.clone(),
16638        name: body.name.trim().to_string(),
16639        created_at: chrono::Utc::now().to_rfc3339(),
16640        params: body.params,
16641    };
16642
16643    let mut store = state.scan_profiles.lock().await;
16644    store.profiles.push(profile);
16645    if let Err(e) = store.save(&state.scan_profiles_path) {
16646        tracing::warn!("failed to persist scan profiles: {e}");
16647    }
16648    drop(store);
16649
16650    (
16651        StatusCode::CREATED,
16652        Json(SaveProfileResponse { ok: true, id }),
16653    )
16654        .into_response()
16655}
16656
16657async fn api_delete_scan_profile(
16658    State(state): State<AppState>,
16659    AxumPath(id): AxumPath<String>,
16660) -> impl IntoResponse {
16661    let mut store = state.scan_profiles.lock().await;
16662    let before = store.profiles.len();
16663    store.profiles.retain(|p| p.id != id);
16664    if store.profiles.len() == before {
16665        drop(store);
16666        return error::not_found("profile not found");
16667    }
16668    if let Err(e) = store.save(&state.scan_profiles_path) {
16669        tracing::warn!("failed to persist scan profiles: {e}");
16670    }
16671    drop(store);
16672    Json(OkResponse { ok: true }).into_response()
16673}
16674
16675fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16676    let value = raw.unwrap_or("out/web").trim();
16677    let path = if value.is_empty() {
16678        PathBuf::from("out/web")
16679    } else {
16680        PathBuf::from(value)
16681    };
16682
16683    if path.is_absolute() {
16684        path
16685    } else {
16686        workspace_root().join(path)
16687    }
16688}
16689
16690/// Derive the directory that holds remote-repo clones from the output root.
16691fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16692    std::env::var("SLOC_GIT_CLONES_DIR")
16693        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16694}
16695
16696/// Build a deterministic filesystem path for a cloned remote repository.
16697/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
16698pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16699    let safe: String = repo_url
16700        .chars()
16701        .map(|c| {
16702            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16703                c
16704            } else {
16705                '_'
16706            }
16707        })
16708        .take(80)
16709        .collect();
16710    clones_dir.join(safe)
16711}
16712
16713/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
16714/// Runs synchronously — call from `tokio::task::spawn_blocking`.
16715pub(crate) fn scan_path_to_artifacts(
16716    scan_path: &Path,
16717    base_config: &AppConfig,
16718    label: &str,
16719) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16720    let mut config = base_config.clone();
16721    config.discovery.root_paths = vec![scan_path.to_path_buf()];
16722    label.clone_into(&mut config.reporting.report_title);
16723    let run = analyze(&config, "git", None, None)?;
16724    let html = render_html(&run)?;
16725    let run_id = run.tool.run_id.clone();
16726    let project_label = sanitize_project_label(label);
16727    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16728    let file_stem = {
16729        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16730        if commit.is_empty() {
16731            project_label
16732        } else {
16733            format!("{project_label}_{commit}")
16734        }
16735    };
16736    let (artifacts, _pending_pdf) = persist_run_artifacts(
16737        &run,
16738        &html,
16739        &output_dir,
16740        label,
16741        &file_stem,
16742        RunResultContext::default(),
16743    )?;
16744    Ok((run_id, artifacts, run))
16745}
16746
16747/// Re-spawn background poll tasks for any polling schedules saved to disk.
16748async fn restart_poll_schedules(state: &AppState) {
16749    let store = state.schedules.lock().await;
16750    let poll_schedules: Vec<_> = store
16751        .schedules
16752        .iter()
16753        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16754        .cloned()
16755        .collect();
16756    drop(store);
16757    for schedule in poll_schedules {
16758        let interval = schedule.interval_secs.unwrap_or(300);
16759        let st = state.clone();
16760        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16761    }
16762}
16763
16764/// Warn at startup when GitLab webhook schedules exist but native TLS is not
16765/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
16766/// header (no HMAC over the body), so the token is exposed in cleartext unless
16767/// the transport is encrypted. This is only an advisory — TLS may be terminated
16768/// by an upstream reverse proxy, in which case the warning can be ignored.
16769async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16770    if state.tls_enabled {
16771        return;
16772    }
16773    let store = state.schedules.lock().await;
16774    let has_gitlab_webhook = store.schedules.iter().any(|s| {
16775        s.kind == sloc_git::ScanScheduleKind::Webhook
16776            && s.provider == sloc_git::ScanScheduleProvider::GitLab
16777    });
16778    drop(store);
16779    if has_gitlab_webhook {
16780        tracing::warn!(
16781            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16782             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16783             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16784             proxy so the token is not exposed in cleartext."
16785        );
16786    }
16787}
16788
16789fn split_patterns(raw: Option<&str>) -> Vec<String> {
16790    raw.unwrap_or("")
16791        .lines()
16792        .flat_map(|line| line.split(','))
16793        .map(str::trim)
16794        .filter(|part| !part.is_empty())
16795        .map(ToOwned::to_owned)
16796        .collect()
16797}
16798
16799#[must_use]
16800pub fn build_sub_run(
16801    parent: &AnalysisRun,
16802    sub: &sloc_core::SubmoduleSummary,
16803    parent_path: &str,
16804) -> AnalysisRun {
16805    let sub_files: Vec<_> = parent
16806        .per_file_records
16807        .iter()
16808        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16809        .cloned()
16810        .collect();
16811    let mut config = parent.effective_configuration.clone();
16812    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16813
16814    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
16815    let mut functions = 0u64;
16816    let mut classes = 0u64;
16817    let mut variables = 0u64;
16818    let mut imports = 0u64;
16819    let mut test_count = 0u64;
16820    let mut test_assertion_count = 0u64;
16821    let mut test_suite_count = 0u64;
16822    let mut mixed_lines_separate = 0u64;
16823    let mut coverage_lines_found = 0u64;
16824    let mut coverage_lines_hit = 0u64;
16825    let mut coverage_functions_found = 0u64;
16826    let mut coverage_functions_hit = 0u64;
16827    let mut coverage_branches_found = 0u64;
16828    let mut coverage_branches_hit = 0u64;
16829    for r in &sub_files {
16830        functions += r.raw_line_categories.functions;
16831        classes += r.raw_line_categories.classes;
16832        variables += r.raw_line_categories.variables;
16833        imports += r.raw_line_categories.imports;
16834        test_count += r.raw_line_categories.test_count;
16835        test_assertion_count += r.raw_line_categories.test_assertion_count;
16836        test_suite_count += r.raw_line_categories.test_suite_count;
16837        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16838        if let Some(cov) = &r.coverage {
16839            coverage_lines_found += u64::from(cov.lines_found);
16840            coverage_lines_hit += u64::from(cov.lines_hit);
16841            coverage_functions_found += u64::from(cov.functions_found);
16842            coverage_functions_hit += u64::from(cov.functions_hit);
16843            coverage_branches_found += u64::from(cov.branches_found);
16844            coverage_branches_hit += u64::from(cov.branches_hit);
16845        }
16846    }
16847
16848    AnalysisRun {
16849        tool: parent.tool.clone(),
16850        environment: parent.environment.clone(),
16851        effective_configuration: config,
16852        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16853        summary_totals: SummaryTotals {
16854            files_considered: sub.files_analyzed,
16855            files_analyzed: sub.files_analyzed,
16856            files_skipped: 0,
16857            total_physical_lines: sub.total_physical_lines,
16858            code_lines: sub.code_lines,
16859            comment_lines: sub.comment_lines,
16860            blank_lines: sub.blank_lines,
16861            mixed_lines_separate,
16862            functions,
16863            classes,
16864            variables,
16865            imports,
16866            test_count,
16867            test_assertion_count,
16868            test_suite_count,
16869            coverage_lines_found,
16870            coverage_lines_hit,
16871            coverage_functions_found,
16872            coverage_functions_hit,
16873            coverage_branches_found,
16874            coverage_branches_hit,
16875            cyclomatic_complexity: 0,
16876            lsloc: None,
16877            ..Default::default()
16878        },
16879        totals_by_language: sub.language_summaries.clone(),
16880        per_file_records: sub_files,
16881        skipped_file_records: vec![],
16882        warnings: vec![],
16883        submodule_summaries: vec![],
16884        git_commit_short: sub.git_commit_short.clone(),
16885        git_commit_long: sub.git_commit_long.clone(),
16886        git_branch: sub.git_branch.clone(),
16887        git_commit_author: sub.git_commit_author.clone(),
16888        git_commit_date: sub.git_commit_date.clone(),
16889        git_tags: None,
16890        git_nearest_tag: None,
16891        git_remote_url: sub.git_remote_url.clone(),
16892        style_summary: None,
16893        cocomo: None,
16894        uloc: 0,
16895        dryness_pct: None,
16896        duplicate_groups: vec![],
16897        duplicates_excluded: 0,
16898    }
16899}
16900
16901#[must_use]
16902pub fn sanitize_project_label(raw: &str) -> String {
16903    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
16904    // where `Path` treats '\' as a literal character, not a separator.
16905    let candidate = raw
16906        .split(['/', '\\'])
16907        .rfind(|s| !s.is_empty())
16908        .unwrap_or("project");
16909
16910    let mut value = String::with_capacity(candidate.len());
16911    for ch in candidate.chars() {
16912        if ch.is_ascii_alphanumeric() {
16913            value.push(ch.to_ascii_lowercase());
16914        } else {
16915            value.push('-');
16916        }
16917    }
16918
16919    let compact = value.trim_matches('-').to_string();
16920    if compact.is_empty() {
16921        "project".to_string()
16922    } else {
16923        compact
16924    }
16925}
16926
16927/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
16928/// comparisons with non-canonicalized stored paths work correctly.
16929fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16930    let s = path.to_string_lossy();
16931    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16932        return PathBuf::from(format!(r"\\{rest}"));
16933    }
16934    if let Some(rest) = s.strip_prefix(r"\\?\") {
16935        return PathBuf::from(rest);
16936    }
16937    path
16938}
16939
16940/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
16941/// commit page URL for the most common hosting platforms.
16942fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16943    let base = if let Some(rest) = remote.strip_prefix("git@") {
16944        let (host, path) = rest.split_once(':')?;
16945        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16946    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16947        remote
16948            .trim_end_matches('/')
16949            .trim_end_matches(".git")
16950            .to_owned()
16951    } else {
16952        return None;
16953    };
16954    let base = base.trim_end_matches('/');
16955    // GitLab uses /-/commit/; everything else uses /commit/
16956    if base.contains("gitlab.com") || base.contains("gitlab.") {
16957        Some(format!("{base}/-/commit/{sha}"))
16958    } else if base.contains("bitbucket.org") {
16959        Some(format!("{base}/commits/{sha}"))
16960    } else {
16961        Some(format!("{base}/commit/{sha}"))
16962    }
16963}
16964
16965/// Convert a git remote URL (https or git@) + branch name into a browser-openable
16966/// branch page URL for the most common hosting platforms.
16967fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16968    let base = if let Some(rest) = remote.strip_prefix("git@") {
16969        let (host, path) = rest.split_once(':')?;
16970        format!("https://{}/{}", host, path.trim_end_matches(".git"))
16971    } else if remote.starts_with("https://") || remote.starts_with("http://") {
16972        remote
16973            .trim_end_matches('/')
16974            .trim_end_matches(".git")
16975            .to_owned()
16976    } else {
16977        return None;
16978    };
16979    let base = base.trim_end_matches('/');
16980    if base.contains("gitlab.com") || base.contains("gitlab.") {
16981        Some(format!("{base}/-/tree/{branch}"))
16982    } else {
16983        Some(format!("{base}/tree/{branch}"))
16984    }
16985}
16986
16987fn display_path(path: &Path) -> String {
16988    let s = path.to_string_lossy();
16989    // Strip Windows extended-length prefix for display only; the underlying
16990    // PathBuf remains unchanged so file operations are unaffected.
16991    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
16992    // \\?\C:\path           →  C:\path          (local drive)
16993    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16994        return format!(r"\\{rest}");
16995    }
16996    if let Some(rest) = s.strip_prefix(r"\\?\") {
16997        return rest.to_owned();
16998    }
16999    s.into_owned()
17000}
17001
17002fn sanitize_path_str(s: &str) -> String {
17003    // Forward-slash variants of the Windows extended-length prefix that appear
17004    // when paths stored as plain strings have been processed through some path
17005    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
17006    if let Some(rest) = s.strip_prefix("//?/UNC/") {
17007        return format!("//{rest}");
17008    }
17009    if let Some(rest) = s.strip_prefix("//?/") {
17010        return rest.to_owned();
17011    }
17012    display_path(Path::new(s))
17013}
17014
17015fn workspace_root() -> PathBuf {
17016    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
17017    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17018        let p = PathBuf::from(root);
17019        if p.is_dir() {
17020            return p;
17021        }
17022    }
17023
17024    // Current working directory — works for `cargo run` from the project root
17025    // and for scripts/run.sh which cds there first.
17026    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17027}
17028
17029/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
17030fn make_git_label(repo: &str, ref_name: &str) -> String {
17031    if repo.is_empty() || ref_name.is_empty() {
17032        return String::new();
17033    }
17034    let base = repo
17035        .trim_end_matches('/')
17036        .trim_end_matches(".git")
17037        .rsplit('/')
17038        .next()
17039        .unwrap_or("repo");
17040    let ref_safe: String = ref_name
17041        .chars()
17042        .map(|c| {
17043            if c.is_alphanumeric() || c == '-' || c == '.' {
17044                c
17045            } else {
17046                '_'
17047            }
17048        })
17049        .collect();
17050    format!("{base}_at_{ref_safe}_sloc")
17051}
17052
17053/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
17054fn desktop_dir() -> PathBuf {
17055    if let Ok(profile) = std::env::var("USERPROFILE") {
17056        let p = PathBuf::from(profile).join("Desktop");
17057        if p.exists() {
17058            return p;
17059        }
17060    }
17061    if let Ok(home) = std::env::var("HOME") {
17062        let p = PathBuf::from(home).join("Desktop");
17063        if p.exists() {
17064            return p;
17065        }
17066    }
17067    workspace_root().join("out").join("web")
17068}
17069
17070fn resolve_input_path(raw: &str) -> PathBuf {
17071    let trimmed = raw.trim();
17072    if trimmed.is_empty() {
17073        return workspace_root().join("samples").join("basic");
17074    }
17075
17076    let candidate = PathBuf::from(trimmed);
17077    let resolved = if candidate.is_absolute() {
17078        candidate
17079    } else {
17080        let rooted = workspace_root().join(&candidate);
17081        if rooted.exists() {
17082            rooted
17083        } else {
17084            workspace_root().join(candidate)
17085        }
17086    };
17087
17088    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
17089    // strip that prefix so stored paths and the displayed "Project path" are clean.
17090    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17091    PathBuf::from(display_path(&canonical))
17092}
17093
17094fn dir_size_bytes(path: &Path) -> u64 {
17095    let mut total = 0u64;
17096    if let Ok(rd) = fs::read_dir(path) {
17097        for entry in rd.filter_map(Result::ok) {
17098            let p = entry.path();
17099            if p.is_file() {
17100                if let Ok(meta) = p.metadata() {
17101                    total += meta.len();
17102                }
17103            } else if p.is_dir() {
17104                total += dir_size_bytes(&p);
17105            }
17106        }
17107    }
17108    total
17109}
17110
17111#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
17112fn format_dir_size(bytes: u64) -> String {
17113    if bytes >= 1_073_741_824 {
17114        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17115    } else if bytes >= 1_048_576 {
17116        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17117    } else if bytes >= 1_024 {
17118        format!("{:.0} KB", bytes as f64 / 1_024.0)
17119    } else {
17120        format!("{bytes} B")
17121    }
17122}
17123
17124fn render_submodule_chips(
17125    root: &Path,
17126    submodules: &[(String, std::path::PathBuf)],
17127    out: &mut String,
17128) {
17129    use std::fmt::Write as _;
17130    let count = submodules.len();
17131    out.push_str(r#"<div class="submodule-preview-strip">"#);
17132    write!(
17133        out,
17134        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>"#,
17135        if count == 1 { "" } else { "s" }
17136    )
17137    .ok();
17138    out.push_str(r#"<div class="submodule-preview-chips">"#);
17139    for (sub_name, sub_rel_path) in submodules {
17140        let sub_abs = root.join(sub_rel_path);
17141        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17142        let mut sub_stats = PreviewStats::default();
17143        let mut sub_rows: Vec<PreviewRow> = Vec::new();
17144        let mut sub_langs: Vec<&'static str> = Vec::new();
17145        let mut sub_budget = PreviewBudget {
17146            shown: 0,
17147            max_entries: 2000,
17148            max_depth: 9,
17149        };
17150        let mut sub_next_id = 1usize;
17151        let _ = collect_preview_rows(
17152            &sub_abs,
17153            &sub_abs,
17154            0,
17155            None,
17156            &mut sub_next_id,
17157            &mut sub_budget,
17158            &mut sub_stats,
17159            &mut sub_rows,
17160            &mut sub_langs,
17161            &[],
17162            &[],
17163        );
17164        let stats_json = format!(
17165            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17166            sub_stats.directories,
17167            sub_stats.files,
17168            sub_stats.supported,
17169            sub_stats.skipped,
17170            sub_stats.unsupported
17171        );
17172        write!(
17173            out,
17174            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>"#,
17175            escape_html(sub_name),
17176            escape_html(&sub_rel_path.to_string_lossy()),
17177            escape_html(&sub_size),
17178            escape_html(&stats_json),
17179            escape_html(sub_name),
17180            escape_html(&sub_size),
17181        )
17182        .ok();
17183    }
17184    out.push_str(
17185        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
17186    );
17187    out.push_str(r"</div>");
17188}
17189
17190/// Amber caution banner shown when the selected folder spans multiple independent
17191/// git repositories. Each repo is a one-click button that re-selects it as the
17192/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
17193fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17194    use std::fmt::Write as _;
17195    const MAX_LISTED: usize = 5;
17196    let total = layout.nested_repos.len();
17197
17198    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17199    if layout.root_is_repo {
17200        write!(
17201            out,
17202            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>",
17203            if total == 1 { "repository" } else { "repositories" }
17204        )
17205        .ok();
17206    } else {
17207        write!(
17208            out,
17209            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>"
17210        )
17211        .ok();
17212    }
17213
17214    out.push_str(r#"<div class="repo-pick-row">"#);
17215    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17216        let abs = root.join(rel);
17217        let abs_display = display_path(&abs);
17218        let label = rel.to_string_lossy().replace('\\', "/");
17219        write!(
17220            out,
17221            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17222            escape_html(&abs_display),
17223            escape_html(&label)
17224        )
17225        .ok();
17226    }
17227    if total > MAX_LISTED {
17228        write!(
17229            out,
17230            r#"<span class="repo-pick-more">and {} more</span>"#,
17231            total - MAX_LISTED
17232        )
17233        .ok();
17234    }
17235    out.push_str(r"</div>");
17236
17237    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17238    out.push_str(r"</div>");
17239}
17240
17241fn render_language_pills_row(languages: &[&str], out: &mut String) {
17242    use std::fmt::Write as _;
17243    if languages.is_empty() {
17244        out.push_str(
17245            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17246        );
17247        return;
17248    }
17249    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17250    for language in languages {
17251        if let Some(icon) = language_icon_file(language) {
17252            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();
17253        } else if let Some(svg) = language_inline_svg(language) {
17254            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();
17255        } else {
17256            write!(
17257                out,
17258                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17259                escape_html(&language.to_ascii_lowercase()),
17260                escape_html(language)
17261            )
17262            .ok();
17263        }
17264    }
17265}
17266
17267#[allow(clippy::too_many_lines)]
17268fn build_preview_html(
17269    root: &Path,
17270    include_patterns: &[String],
17271    exclude_patterns: &[String],
17272) -> Result<String> {
17273    if !root.exists() {
17274        return Ok(format!(
17275            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17276            escape_html(&display_path(root))
17277        ));
17278    }
17279
17280    let _selected = display_path(root);
17281    let mut stats = PreviewStats::default();
17282    let mut rows = Vec::new();
17283    let mut languages = Vec::new();
17284    let mut budget = PreviewBudget {
17285        shown: 0,
17286        max_entries: 600,
17287        max_depth: 9,
17288    };
17289    let mut next_row_id = 1usize;
17290
17291    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17292        || root.to_string_lossy().into_owned(),
17293        std::string::ToString::to_string,
17294    );
17295    let root_modified = root
17296        .metadata()
17297        .ok()
17298        .and_then(|meta| meta.modified().ok())
17299        .map_or_else(|| "-".to_string(), format_system_time);
17300
17301    rows.push(PreviewRow {
17302        row_id: 0,
17303        parent_row_id: None,
17304        depth: 0,
17305        name: format!("{root_name}/"),
17306        kind: PreviewKind::Dir,
17307        is_dir: true,
17308        language: None,
17309        modified: root_modified,
17310        type_label: "Directory".to_string(),
17311    });
17312    collect_preview_rows(
17313        root,
17314        root,
17315        0,
17316        Some(0),
17317        &mut next_row_id,
17318        &mut budget,
17319        &mut stats,
17320        &mut rows,
17321        &mut languages,
17322        include_patterns,
17323        exclude_patterns,
17324    )?;
17325
17326    let root_size = format_dir_size(dir_size_bytes(root));
17327
17328    let mut out = String::new();
17329    write!(
17330        out,
17331        r#"<div class="explorer-wrap" data-project-size="{}">"#,
17332        escape_html(&root_size)
17333    )
17334    .ok();
17335    out.push_str(r#"<div class="explorer-toolbar compact">"#);
17336    out.push_str(r#"<div class="explorer-title-group">"#);
17337    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17338    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17339    out.push_str(r"</div></div>");
17340
17341    out.push_str(r#"<div class="scope-stats">"#);
17342    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();
17343    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();
17344    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();
17345    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();
17346    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();
17347    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>"#);
17348    out.push_str(r"</div>");
17349
17350    let submodules = sloc_core::detect_submodules(root);
17351    if !submodules.is_empty() {
17352        render_submodule_chips(root, &submodules, &mut out);
17353    }
17354
17355    let repo_layout = sloc_core::detect_repository_layout(root);
17356    if repo_layout.has_multiple_repos() {
17357        render_multi_repo_warning(root, &repo_layout, &mut out);
17358    }
17359
17360    out.push_str(r#"<div class="scope-info-row">"#);
17361    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17362    render_language_pills_row(&languages, &mut out);
17363    out.push_str(r"</div></div>");
17364    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>"#);
17365    out.push_str(r"</div>");
17366
17367    out.push_str(r#"<div class="file-explorer-shell">"#);
17368    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>"#);
17369    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>"#);
17370    out.push_str(r#"<div class="file-explorer-tree">"#);
17371    for row in rows {
17372        let status_label = row.kind.label();
17373        let lang_attr = row.language.unwrap_or("");
17374        let toggle_html = if row.is_dir {
17375            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17376                .to_string()
17377        } else {
17378            r#"<span class="tree-bullet">•</span>"#.to_string()
17379        };
17380        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();
17381    }
17382    if budget.shown >= budget.max_entries {
17383        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>"#);
17384    }
17385    out.push_str(r"</div></div></div>");
17386
17387    Ok(out)
17388}
17389
17390#[derive(Default)]
17391struct PreviewStats {
17392    directories: usize,
17393    files: usize,
17394    supported: usize,
17395    skipped: usize,
17396    unsupported: usize,
17397}
17398
17399struct PreviewRow {
17400    row_id: usize,
17401    parent_row_id: Option<usize>,
17402    depth: usize,
17403    name: String,
17404    kind: PreviewKind,
17405    is_dir: bool,
17406    language: Option<&'static str>,
17407    modified: String,
17408    type_label: String,
17409}
17410
17411#[derive(Copy, Clone)]
17412enum PreviewKind {
17413    Dir,
17414    Supported,
17415    Skipped,
17416    Unsupported,
17417}
17418
17419impl PreviewKind {
17420    const fn filter_key(self) -> &'static str {
17421        match self {
17422            Self::Dir => "dir",
17423            Self::Supported => "supported",
17424            Self::Skipped => "skipped",
17425            Self::Unsupported => "unsupported",
17426        }
17427    }
17428
17429    const fn label(self) -> &'static str {
17430        match self {
17431            Self::Dir => "dir",
17432            Self::Supported => "supported",
17433            Self::Skipped => "skipped by policy",
17434            Self::Unsupported => "unsupported",
17435        }
17436    }
17437
17438    const fn badge_class(self) -> &'static str {
17439        match self {
17440            Self::Dir => "badge badge-dir",
17441            Self::Supported => "badge badge-scan",
17442            Self::Skipped => "badge badge-skip",
17443            Self::Unsupported => "badge badge-unsupported",
17444        }
17445    }
17446
17447    const fn node_class(self) -> &'static str {
17448        match self {
17449            Self::Dir => "tree-node-dir",
17450            Self::Supported => "tree-node-supported",
17451            Self::Skipped => "tree-node-skipped",
17452            Self::Unsupported => "tree-node-unsupported",
17453        }
17454    }
17455}
17456
17457struct PreviewBudget {
17458    shown: usize,
17459    max_entries: usize,
17460    max_depth: usize,
17461}
17462
17463/// Handle a single directory entry inside `collect_preview_rows`.
17464/// Returns `true` when the entry was handled (caller should `continue`).
17465#[allow(clippy::too_many_arguments)]
17466fn handle_preview_dir_entry(
17467    root: &Path,
17468    path: &Path,
17469    name: &str,
17470    modified: String,
17471    depth: usize,
17472    parent_row_id: Option<usize>,
17473    row_id: usize,
17474    next_row_id: &mut usize,
17475    budget: &mut PreviewBudget,
17476    stats: &mut PreviewStats,
17477    rows: &mut Vec<PreviewRow>,
17478    languages: &mut Vec<&'static str>,
17479    include_patterns: &[String],
17480    exclude_patterns: &[String],
17481) -> Result<()> {
17482    let relative = preview_relative_path(root, path);
17483    if should_skip_preview_directory(&relative, exclude_patterns) {
17484        return Ok(());
17485    }
17486    stats.directories += 1;
17487    rows.push(PreviewRow {
17488        row_id,
17489        parent_row_id,
17490        depth: depth + 1,
17491        name: format!("{name}/"),
17492        kind: PreviewKind::Dir,
17493        is_dir: true,
17494        language: None,
17495        modified,
17496        type_label: "Directory".to_string(),
17497    });
17498    budget.shown += 1;
17499    if !matches!(name, ".git" | "node_modules" | "target") {
17500        collect_preview_rows(
17501            root,
17502            path,
17503            depth + 1,
17504            Some(row_id),
17505            next_row_id,
17506            budget,
17507            stats,
17508            rows,
17509            languages,
17510            include_patterns,
17511            exclude_patterns,
17512        )?;
17513    }
17514    Ok(())
17515}
17516
17517/// Handle a single file entry inside `collect_preview_rows`.
17518#[allow(clippy::too_many_arguments)]
17519fn handle_preview_file_entry(
17520    root: &Path,
17521    path: &Path,
17522    name: &str,
17523    modified: String,
17524    depth: usize,
17525    parent_row_id: Option<usize>,
17526    row_id: usize,
17527    budget: &mut PreviewBudget,
17528    stats: &mut PreviewStats,
17529    rows: &mut Vec<PreviewRow>,
17530    languages: &mut Vec<&'static str>,
17531    include_patterns: &[String],
17532    exclude_patterns: &[String],
17533) {
17534    let relative = preview_relative_path(root, path);
17535    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17536        return;
17537    }
17538    stats.files += 1;
17539    let kind = classify_preview_file(name);
17540    match kind {
17541        PreviewKind::Supported => stats.supported += 1,
17542        PreviewKind::Skipped => stats.skipped += 1,
17543        PreviewKind::Unsupported => stats.unsupported += 1,
17544        PreviewKind::Dir => {}
17545    }
17546    let language = detect_language_name(name);
17547    if let Some(lang) = language {
17548        if !languages.contains(&lang) {
17549            languages.push(lang);
17550        }
17551    }
17552    rows.push(PreviewRow {
17553        row_id,
17554        parent_row_id,
17555        depth: depth + 1,
17556        name: name.to_owned(),
17557        kind,
17558        is_dir: false,
17559        language,
17560        modified,
17561        type_label: preview_type_label(name, language, kind),
17562    });
17563    budget.shown += 1;
17564}
17565
17566#[allow(clippy::too_many_arguments)]
17567#[allow(clippy::too_many_lines)]
17568fn collect_preview_rows(
17569    root: &Path,
17570    dir: &Path,
17571    depth: usize,
17572    parent_row_id: Option<usize>,
17573    next_row_id: &mut usize,
17574    budget: &mut PreviewBudget,
17575    stats: &mut PreviewStats,
17576    rows: &mut Vec<PreviewRow>,
17577    languages: &mut Vec<&'static str>,
17578    include_patterns: &[String],
17579    exclude_patterns: &[String],
17580) -> Result<()> {
17581    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17582        return Ok(());
17583    }
17584
17585    let mut entries = fs::read_dir(dir)
17586        .with_context(|| format!("failed to read directory {}", dir.display()))?
17587        .filter_map(std::result::Result::ok)
17588        .collect::<Vec<_>>();
17589    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17590
17591    for entry in entries {
17592        if budget.shown >= budget.max_entries {
17593            break;
17594        }
17595
17596        let path = entry.path();
17597        let name = entry.file_name().to_string_lossy().into_owned();
17598        let Ok(metadata) = entry.metadata() else {
17599            continue;
17600        };
17601        let row_id = *next_row_id;
17602        *next_row_id += 1;
17603        let modified = metadata
17604            .modified()
17605            .ok()
17606            .map_or_else(|| "-".to_string(), format_system_time);
17607
17608        if metadata.is_dir() {
17609            handle_preview_dir_entry(
17610                root,
17611                &path,
17612                &name,
17613                modified,
17614                depth,
17615                parent_row_id,
17616                row_id,
17617                next_row_id,
17618                budget,
17619                stats,
17620                rows,
17621                languages,
17622                include_patterns,
17623                exclude_patterns,
17624            )?;
17625            continue;
17626        }
17627
17628        if metadata.is_file() {
17629            handle_preview_file_entry(
17630                root,
17631                &path,
17632                &name,
17633                modified,
17634                depth,
17635                parent_row_id,
17636                row_id,
17637                budget,
17638                stats,
17639                rows,
17640                languages,
17641                include_patterns,
17642                exclude_patterns,
17643            );
17644        }
17645    }
17646
17647    Ok(())
17648}
17649
17650fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17651    if let Some(language) = language {
17652        return format!("{language} source");
17653    }
17654    let lower = name.to_ascii_lowercase();
17655    let ext = Path::new(&lower)
17656        .extension()
17657        .and_then(|e| e.to_str())
17658        .unwrap_or("");
17659    match kind {
17660        PreviewKind::Skipped => {
17661            if lower.ends_with(".min.js") {
17662                "Minified asset".to_string()
17663            } else if [
17664                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17665            ]
17666            .contains(&ext)
17667            {
17668                "Binary or archive".to_string()
17669            } else {
17670                "Skipped file".to_string()
17671            }
17672        }
17673        PreviewKind::Unsupported => {
17674            if ext.is_empty() {
17675                "Unsupported file".to_string()
17676            } else {
17677                format!("{} file", ext.to_ascii_uppercase())
17678            }
17679        }
17680        PreviewKind::Supported => "Supported source".to_string(),
17681        PreviewKind::Dir => "Directory".to_string(),
17682    }
17683}
17684
17685fn format_system_time(time: SystemTime) -> String {
17686    #[allow(clippy::cast_possible_wrap)]
17687    let secs = match time.duration_since(UNIX_EPOCH) {
17688        Ok(duration) => duration.as_secs() as i64,
17689        Err(_) => return "-".to_string(),
17690    };
17691    let days = secs.div_euclid(86_400);
17692    let secs_of_day = secs.rem_euclid(86_400);
17693    let (year, month, day) = civil_from_days(days);
17694    let hour = secs_of_day / 3_600;
17695    let minute = (secs_of_day % 3_600) / 60;
17696    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17697}
17698
17699#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17700fn civil_from_days(days: i64) -> (i32, u32, u32) {
17701    let z = days + 719_468;
17702    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17703    let doe = z - era * 146_097;
17704    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17705    let y = yoe + era * 400;
17706    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17707    let mp = (5 * doy + 2) / 153;
17708    let d = doy - (153 * mp + 2) / 5 + 1;
17709    let m = mp + if mp < 10 { 3 } else { -9 };
17710    let year = y + i64::from(m <= 2);
17711    (year as i32, m as u32, d as u32)
17712}
17713
17714// The input is already lowercased via `to_ascii_lowercase()` before calling
17715// `ends_with`, so the comparisons are inherently case-insensitive.
17716#[allow(clippy::case_sensitive_file_extension_comparisons)]
17717fn detect_language_name(name: &str) -> Option<&'static str> {
17718    let lower = name.to_ascii_lowercase();
17719    if lower.ends_with(".c") || lower.ends_with(".h") {
17720        Some("C")
17721    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17722        .iter()
17723        .any(|s| lower.ends_with(s))
17724    {
17725        Some("C++")
17726    } else if lower.ends_with(".cs") {
17727        Some("C#")
17728    } else if lower.ends_with(".py") {
17729        Some("Python")
17730    } else if lower.ends_with(".sh") {
17731        Some("Shell")
17732    } else if [".ps1", ".psm1", ".psd1"]
17733        .iter()
17734        .any(|s| lower.ends_with(s))
17735    {
17736        Some("PowerShell")
17737    } else {
17738        None
17739    }
17740}
17741
17742fn language_icon_file(language: &str) -> Option<&'static str> {
17743    match language {
17744        "C" => Some("c.png"),
17745        "C++" => Some("cpp.png"),
17746        "C#" => Some("c-sharp.png"),
17747        "Python" => Some("python.png"),
17748        "Shell" => Some("shell.png"),
17749        "PowerShell" => Some("powershell.png"),
17750        "JavaScript" => Some("java-script.png"),
17751        "HTML" => Some("html-5.png"),
17752        "Java" => Some("java.png"),
17753        "Visual Basic" => Some("visual-basic.png"),
17754        "Assembly" => Some("asm.png"),
17755        "Go" => Some("go.png"),
17756        "R" => Some("r.png"),
17757        "XML" => Some("xml.png"),
17758        "Groovy" => Some("groovy.png"),
17759        "Dockerfile" => Some("docker.png"),
17760        "Makefile" => Some("makefile.svg"),
17761        "Perl" => Some("perl.svg"),
17762        _ => None,
17763    }
17764}
17765
17766// Inline SVG badges for languages that have no PNG icon in images/icons/.
17767// Using inline SVG keeps the web UI fully self-contained — no extra files
17768// needed on disk, no 404s on air-gapped deployments.
17769// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
17770fn language_inline_svg(language: &str) -> Option<&'static str> {
17771    match language {
17772        "Rust" => Some(
17773            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>"##,
17774        ),
17775        "TypeScript" => Some(
17776            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>"##,
17777        ),
17778        _ => None,
17779    }
17780}
17781
17782// The input is already lowercased via `to_ascii_lowercase()` before the
17783// `ends_with` calls, so these comparisons are inherently case-insensitive.
17784#[allow(clippy::case_sensitive_file_extension_comparisons)]
17785fn classify_preview_file(name: &str) -> PreviewKind {
17786    let lower = name.to_ascii_lowercase();
17787
17788    let scannable = [
17789        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17790        ".psm1", ".psd1",
17791    ]
17792    .iter()
17793    .any(|suffix| lower.ends_with(suffix));
17794
17795    if scannable {
17796        PreviewKind::Supported
17797    } else if lower.ends_with(".min.js")
17798        || lower.ends_with(".lock")
17799        || lower.ends_with(".png")
17800        || lower.ends_with(".jpg")
17801        || lower.ends_with(".jpeg")
17802        || lower.ends_with(".gif")
17803        || lower.ends_with(".zip")
17804        || lower.ends_with(".pdf")
17805        || lower.ends_with(".pyc")
17806        || lower.ends_with(".xz")
17807        || lower.ends_with(".tar")
17808        || lower.ends_with(".gz")
17809    {
17810        PreviewKind::Skipped
17811    } else {
17812        PreviewKind::Unsupported
17813    }
17814}
17815
17816fn preview_relative_path(root: &Path, path: &Path) -> String {
17817    path.strip_prefix(root)
17818        .ok()
17819        .unwrap_or(path)
17820        .to_string_lossy()
17821        .replace('\\', "/")
17822        .trim_matches('/')
17823        .to_string()
17824}
17825
17826fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17827    if relative.is_empty() {
17828        return false;
17829    }
17830
17831    exclude_patterns.iter().any(|pattern| {
17832        wildcard_match(pattern, relative)
17833            || wildcard_match(pattern, &format!("{relative}/"))
17834            || wildcard_match(pattern, &format!("{relative}/placeholder"))
17835    })
17836}
17837
17838fn should_include_preview_file(
17839    relative: &str,
17840    include_patterns: &[String],
17841    exclude_patterns: &[String],
17842) -> bool {
17843    if relative.is_empty() {
17844        return true;
17845    }
17846
17847    let included = include_patterns.is_empty()
17848        || include_patterns
17849            .iter()
17850            .any(|pattern| wildcard_match(pattern, relative));
17851    let excluded = exclude_patterns
17852        .iter()
17853        .any(|pattern| wildcard_match(pattern, relative));
17854
17855    included && !excluded
17856}
17857
17858fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17859    let pattern = pattern.trim().replace('\\', "/");
17860    let candidate = candidate.trim().replace('\\', "/");
17861    let p = pattern.as_bytes();
17862    let c = candidate.as_bytes();
17863    let mut pi = 0usize;
17864    let mut ci = 0usize;
17865    let mut star: Option<usize> = None;
17866    let mut star_match = 0usize;
17867
17868    while ci < c.len() {
17869        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17870            pi += 1;
17871            ci += 1;
17872        } else if pi < p.len() && p[pi] == b'*' {
17873            while pi < p.len() && p[pi] == b'*' {
17874                pi += 1;
17875            }
17876            star = Some(pi);
17877            star_match = ci;
17878        } else if let Some(star_pi) = star {
17879            star_match += 1;
17880            ci = star_match;
17881            pi = star_pi;
17882        } else {
17883            return false;
17884        }
17885    }
17886
17887    while pi < p.len() && p[pi] == b'*' {
17888        pi += 1;
17889    }
17890
17891    pi == p.len()
17892}
17893
17894fn escape_html(value: &str) -> String {
17895    value
17896        .replace('&', "&amp;")
17897        .replace('<', "&lt;")
17898        .replace('>', "&gt;")
17899        .replace('"', "&quot;")
17900        .replace('\'', "&#39;")
17901}
17902
17903#[derive(Clone)]
17904struct SubmoduleRow {
17905    name: String,
17906    relative_path: String,
17907    files_analyzed: u64,
17908    code_lines: u64,
17909    comment_lines: u64,
17910    blank_lines: u64,
17911    total_physical_lines: u64,
17912    html_url: Option<String>,
17913}
17914
17915#[derive(Template)]
17916#[template(
17917    source = r##"
17918<!doctype html>
17919<html lang="en">
17920<head>
17921  <meta charset="utf-8">
17922  <title>OxideSLOC | tmp-sloc</title>
17923  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17924  <style nonce="{{ csp_nonce }}">
17925    :root {
17926      --bg: #efe9e2;
17927      --surface: #fcfaf7;
17928      --surface-2: #f7f0e8;
17929      --surface-3: #efe3d5;
17930      --line: #dfcfbf;
17931      --line-strong: #cfb29c;
17932      --text: #2f241c;
17933      --muted: #6f6257;
17934      --muted-2: #917f71;
17935      --nav: #b85d33;
17936      --nav-2: #7a371b;
17937      --accent: #2563eb;
17938      --accent-2: #1d4ed8;
17939      --oxide: #b85d33;
17940      --oxide-2: #8f4220;
17941      --success-bg: #eaf9ee;
17942      --success-text: #1c8746;
17943      --warn-bg: #fff2d8;
17944      --warn-text: #926000;
17945      --danger-bg: #fdeaea;
17946      --danger-text: #b33b3b;
17947      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17948      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17949      --radius: 14px;
17950    }
17951
17952    body.dark-theme {
17953      --bg: #1b1511;
17954      --surface: #261c17;
17955      --surface-2: #2d221d;
17956      --surface-3: #372922;
17957      --line: #524238;
17958      --line-strong: #6c5649;
17959      --text: #f5ece6;
17960      --muted: #c7b7aa;
17961      --muted-2: #aa9485;
17962      --nav: #b85d33;
17963      --nav-2: #7a371b;
17964      --accent: #6f9bff;
17965      --accent-2: #4a78ee;
17966      --oxide: #d37a4c;
17967      --oxide-2: #b35428;
17968      --success-bg: #163927;
17969      --success-text: #8fe2a8;
17970      --warn-bg: #3c2d11;
17971      --warn-text: #f3cb75;
17972      --danger-bg: #3d1f1f;
17973      --danger-text: #ff9f9f;
17974      --shadow: 0 14px 28px rgba(0,0,0,0.28);
17975      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17976    }
17977
17978    * { box-sizing: border-box; }
17979    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); }
17980    html { overflow-y: scroll; }
17981    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17982    .top-nav, .page, .loading { position: relative; z-index: 2; }
17983    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17984    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17985    .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); }
17986    .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; }
17987    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17988    .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)); }
17989    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17990    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17991    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17992    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17993    .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; }
17994    .nav-project-pill.visible { display:inline-flex; }
17995    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17996    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17997    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17998    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17999    @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; } }
18000    .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; }
18001    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18002    .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; }
18003    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18004    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18005    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18006    .theme-toggle .icon-sun { display:none; }
18007    body.dark-theme .theme-toggle .icon-sun { display:block; }
18008    body.dark-theme .theme-toggle .icon-moon { display:none; }
18009    .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;}
18010    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18011    .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);}
18012    .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;}
18013    .settings-close:hover{color:var(--text);background:var(--surface-2);}
18014    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18015    .settings-modal-body{padding:14px 16px 16px;}
18016    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18017    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18018    .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;}
18019    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18020    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18021    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18022    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18023    .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;}
18024    .tz-select:focus{border-color:var(--oxide);}
18025    .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; }
18026    .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;}
18027    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18028    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18029    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18030    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18031    .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; }
18032    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18033    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18034    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18035    .wb-stats-header { padding: 10px 24px 0; }
18036    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18037    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18038    .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; }
18039    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18040    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18041    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18042    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18043    .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; }
18044    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18045    .ws-stat-analyzers { position: relative; }
18046    .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; }
18047    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18048    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18049    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18050    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18051    .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; }
18052    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18053    .ws-divider { display: none; }
18054    .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%; }
18055    .ws-path-link:hover { color:var(--oxide); }
18056    body.dark-theme .ws-path-link { color:var(--oxide); }
18057    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18058    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18059    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18060    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18061    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18062    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18063    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18064    .ws-mini-box-lg { flex:2 1 0; }
18065    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18066    .ws-mini-box-br { flex:1.5 1 0; }
18067    .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; }
18068    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18069    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18070    #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; }
18071    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18072    .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; }
18073    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18074    .git-source-banner strong { font-weight:800; color:var(--text); }
18075    .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; }
18076    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18077    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18078    .git-source-banner a:hover { text-decoration:underline; }
18079    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18080    .path-scope-sep { background:var(--line); margin:4px 14px; }
18081    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18082    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18083    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18084    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18085    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18086    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18087    .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; }
18088    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18089    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18090    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18091    .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; }
18092    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18093    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18094    [data-wb-tip] { cursor:help; }
18095    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18096    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18097    .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; }
18098    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18099    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18100    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18101    .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; }
18102    .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); }
18103    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18104    .side-info-card { padding: 18px; }
18105    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18106    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18107    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18108    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18109    .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); }
18110    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18111    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18112    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18113    .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; }
18114    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18115    .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; }
18116    .side-stack::-webkit-scrollbar { display: none; }
18117    .step-nav { padding: 20px 16px; }
18118    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18119    .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; }
18120    .step-button:hover { background: var(--surface-2); }
18121    .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); }
18122    .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; }
18123    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18124    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18125    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18126    .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); }
18127    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18128    .step-nav-sum-row:last-child { border-bottom:none; }
18129    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18130    .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; }
18131    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18132    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18133    .quick-scan-section { padding: 10px 4px 14px; }
18134    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18135    .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; }
18136    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18137    .quick-scan-btn:active { transform:translateY(0); }
18138    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18139    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18140    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18141    @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);} }
18142    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18143    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18144    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18145    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18146    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18147    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18148    .step-button.done .step-check { opacity:1; }
18149    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18150    .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; }
18151    .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; }
18152    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18153    .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; }
18154    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18155    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18156    .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; }
18157    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18158    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18159    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18160    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18161    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18162    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18163    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18164    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18165    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18166    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18167    .card-body { padding: 22px; }
18168    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18169    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18170    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18171    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18172    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18173    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18174    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18175    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18176    .field { min-width:0; }
18177    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18178    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; }
18179    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); }
18180    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18181    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); }
18182    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18183    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18184    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18185    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18186    .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; }
18187    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18188    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18189    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18190    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18191    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18192    .input-group.compact { grid-template-columns: 1fr auto auto; }
18193    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18194    .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)); }
18195    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18196    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18197    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18198    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18199    .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; }
18200    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18201    .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; }
18202    .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); }
18203    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18204    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18205    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18206    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18207    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18208    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18209    @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; } }
18210    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18211    button.secondary { background: var(--surface); }
18212    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18213    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18214    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18215    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18216    .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); }
18217    .section + .wizard-actions { border-top: none; padding-top: 0; }
18218    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18219    .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; }
18220    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18221    .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; }
18222    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18223    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18224    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18225    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18226    .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); }
18227    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18228    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18229    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18230    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18231    .field-help-grid.coupled-help { margin-top: 12px; }
18232    .field-help-grid.preset-grid { align-items: start; }
18233    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18234    .preset-inline-row .field { margin: 0; }
18235    .preset-inline-row .explainer-card { margin: 0; }
18236    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18237    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18238    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18239    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18240    .preset-kv-row > :last-child { flex:1; min-width:0; }
18241    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18242    .output-field-row .field { margin: 0; }
18243    .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; }
18244    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18245    .step3-subtitle { margin-bottom: 10px; max-width: none; }
18246    .counting-intro { margin-bottom: 8px; max-width: none; }
18247    .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; }
18248    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18249    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18250    .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; }
18251    .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; }
18252    .section-spacer-top { margin-top: 28px; }
18253    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18254    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18255    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18256    .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); }
18257    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18258    .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; }
18259    .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; }
18260    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18261    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18262    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18263    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18264    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18265    .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; }
18266    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18267    .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); }
18268    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; }
18269    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; }
18270    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18271    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18272    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18273    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18274    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18275    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18276    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18277    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18278    .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); }
18279    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18280    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18281    .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; }
18282    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18283    .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; }
18284    .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; }
18285    .always-tracked-tip-body { flex:1; min-width:0; }
18286    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18287    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18288    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18289    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18290    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18291    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18292    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18293    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18294    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18295    .advanced-rule-description strong { color: var(--text); }
18296    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18297    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18298    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18299    .review-link:hover { text-decoration: underline; }
18300    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18301    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18302    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18303    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18304    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18305    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18306    .review-card ul { padding-left: 18px; margin: 0; }
18307    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18308    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18309    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18310    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18311    .review-card { min-height: 0; }
18312    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18313    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18314    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18315    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18316    .lang-overflow-chip { position:relative; cursor:default; }
18317    .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; }
18318    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18319    .git-inline-row { align-items:start; }
18320    .mixed-line-card { display:flex; flex-direction:column; }
18321    .preset-inline-row .toggle-card { justify-content: center; }
18322        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18323    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18324    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18325    .explorer-title { font-size: 18px; font-weight: 850; }
18326    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18327    .explorer-subtitle.wide { max-width: none; }
18328    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18329    .better-spacing { align-items:flex-start; justify-content:flex-end; }
18330    .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; }
18331    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18332    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18333    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18334    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18335    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18336    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18337    .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; }
18338    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18339    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18340    .scope-stat-button.supported { background: var(--success-bg); }
18341    .scope-stat-button.skipped { background: var(--warn-bg); }
18342    .scope-stat-button.unsupported { background: var(--danger-bg); }
18343    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18344    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18345    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18346    [data-tooltip] { position: relative; }
18347    [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); }
18348    [data-tooltip]:hover::after { display: block; }
18349    .scope-stat-button[data-tooltip] { cursor: pointer; }
18350    .badge[data-tooltip] { cursor: help; }
18351    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18352    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18353    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18354    .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; }
18355    .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; }
18356    code { display:inline-block; margin-top:0; padding:2px 7px; }
18357    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18358    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18359    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18360    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18361    .language-pill.muted-pill { color: var(--muted); }
18362    button.language-pill { appearance:none; cursor:pointer; }
18363    .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); }
18364    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18365    .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; }
18366    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18367    .file-explorer-search-row { margin-left: auto; }
18368    .explorer-filter-select { min-width: 170px; width: 170px; }
18369    .explorer-search { min-width: 300px; width: 300px; }
18370    .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); }
18371    .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; }
18372    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18373    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18374    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18375    .file-explorer-tree { max-height: 640px; overflow:auto; }
18376    .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); }
18377    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18378    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18379    .tree-row.hidden-by-filter { display:none !important; }
18380    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18381    .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; }
18382    .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; }
18383    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18384    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18385    .tree-node { display:inline-flex; align-items:center; min-width:0; }
18386    .tree-node-dir { color: var(--text); font-weight: 800; }
18387    .tree-node-supported { color: var(--success-text); }
18388    .tree-node-skipped { color: var(--warn-text); }
18389    .tree-node-unsupported { color: var(--danger-text); }
18390    .tree-node-more { color: var(--muted-2); font-style: italic; }
18391    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18392    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18393    .tree-status-cell { display:flex; justify-content:flex-start; }
18394    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18395    .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; }
18396    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18397    .preview-warning p { margin: 0 0 10px; }
18398    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18399    .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; }
18400    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18401    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18402    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18403    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18404    .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; }
18405    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18406    .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; }
18407    @keyframes prevSpin { to { transform:rotate(360deg); } }
18408    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18409    .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; }
18410    .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; }
18411    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18412    .preview-gate-info svg { width:16px; height:16px; }
18413    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18414    @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); } }
18415    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18416    .preview-loading-text { flex:1; min-width:0; }
18417    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18418    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18419    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18420    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18421    .cov-scan-idle { display:none; }
18422    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18423    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18424    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18425    .cov-scan-title { font-weight:600; font-size:12.5px; }
18426    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18427    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18428    .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; }
18429    .cov-scan-use:hover { opacity:.75; }
18430    .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; }
18431    .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; }
18432    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18433    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18434    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18435    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18436    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18437    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18438    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18439    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18440    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18441    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18442    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18443    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18444    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18445    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18446    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18447    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18448    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18449    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18450    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18451    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18452    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18453    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18454    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18455    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18456    .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); }
18457    .loading.active { display:flex; }
18458    /* Lock page scroll while the analysis modal is open so the removed scrollbar
18459       gutter doesn't pull the centered card slightly left of true center. */
18460    body.modal-open { overflow: hidden; }
18461    .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; }
18462    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18463    .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; }
18464    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18465    .loading-card > * { position:relative; z-index:1; }
18466    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18467    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%); }
18468    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18469    .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; }
18470    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18471    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18472    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18473    .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; }
18474    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18475    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18476    .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; }
18477    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18478    .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; }
18479    .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18480    .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; }
18481    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18482    .lc-step.done { color:var(--muted);opacity:0.55; }
18483    .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; }
18484    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18485    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18486    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18487    .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; }
18488    .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; }
18489    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18490    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18491    .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; }
18492    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18493    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18494    .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; }
18495    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18496    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18497    .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; }
18498    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18499    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18500    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18501    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18502    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18503    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18504    .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; }
18505    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18506    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18507    .hidden { display:none !important; }
18508    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18509    .site-footer a{color:var(--muted);}
18510    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18511    @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; } }
18512    .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;}
18513    @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));}}
18514    .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;}
18515    .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; }
18516    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18517    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18518    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18519    .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; }
18520    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18521    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18522    .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; }
18523    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18524    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18525    .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; }
18526    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18527    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18528    .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; }
18529    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18530    .info-icon-btn:hover { color:var(--text); }
18531    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); }
18532    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18533    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18534    .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;}
18535    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18536    .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;}
18537    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18538    #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);}
18539    #offline-file-banner.show{display:flex;}
18540    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18541    #offline-file-banner .ofb-text{flex:1;}
18542    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18543    #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;}
18544    #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;}
18545    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18546    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18547    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18548    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18549    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18550    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18551    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18552  </style>
18553</head>
18554<body id="page-top">
18555  <div id="offline-file-banner" role="alert">
18556    <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>
18557    <span class="ofb-text">
18558      Charts, images, and navigation require the oxide-sloc server.
18559      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18560      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18561      The metric tables below are fully readable without the server.
18562    </span>
18563    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18564  </div>
18565  <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>
18566  <div class="background-watermarks" aria-hidden="true">
18567    <img src="/images/logo/logo-text.png" alt="" />
18568    <img src="/images/logo/logo-text.png" alt="" />
18569    <img src="/images/logo/logo-text.png" alt="" />
18570    <img src="/images/logo/logo-text.png" alt="" />
18571    <img src="/images/logo/logo-text.png" alt="" />
18572    <img src="/images/logo/logo-text.png" alt="" />
18573    <img src="/images/logo/logo-text.png" alt="" />
18574    <img src="/images/logo/logo-text.png" alt="" />
18575    <img src="/images/logo/logo-text.png" alt="" />
18576    <img src="/images/logo/logo-text.png" alt="" />
18577    <img src="/images/logo/logo-text.png" alt="" />
18578    <img src="/images/logo/logo-text.png" alt="" />
18579    <img src="/images/logo/logo-text.png" alt="" />
18580    <img src="/images/logo/logo-text.png" alt="" />
18581  </div>
18582  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18583  <div class="top-nav">
18584    <div class="top-nav-inner">
18585      <a class="brand" href="/">
18586        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18587        <div class="brand-copy">
18588          <div class="brand-title">OxideSLOC</div>
18589          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18590        </div>
18591      </a>
18592      <div class="nav-project-slot">
18593        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18594          <span class="nav-project-label">Project</span>
18595          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18596        </div>
18597      </div>
18598      <div class="nav-status">
18599        <a class="nav-pill" href="/">Home</a>
18600        <div class="nav-dropdown">
18601          <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>
18602          <div class="nav-dropdown-menu">
18603            <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>
18604          </div>
18605        </div>
18606        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18607        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18608        <div class="nav-dropdown">
18609          <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>
18610          <div class="nav-dropdown-menu">
18611            <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>
18612          </div>
18613        </div>
18614        <div class="server-status-wrap" id="server-status-wrap">
18615          <div class="nav-pill server-online-pill" id="server-status-pill">
18616            <span class="status-dot" id="status-dot"></span>
18617            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18618            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18619          </div>
18620          <div class="server-status-tip">
18621            {% if server_mode %}
18622            OxideSLOC is running in server mode — accessible on your LAN.
18623            {% else %}
18624            OxideSLOC is running locally — only accessible from this machine.
18625            {% endif %}
18626            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18627          </div>
18628        </div>
18629        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18630          <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>
18631        </button>
18632        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18633          <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>
18634          <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>
18635        </button>
18636      </div>
18637    </div>
18638  </div>
18639
18640  <div class="loading" id="loading">
18641    <div class="loading-card" id="loading-card">
18642      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18643      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18644      <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>
18645      <div class="lc-steps" id="lc-steps">
18646        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18647        <div class="lc-step-arrow">›</div>
18648        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18649        <div class="lc-step-arrow">›</div>
18650        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18651        <div class="lc-step-arrow">›</div>
18652        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18653      </div>
18654      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18655      <div class="lc-metrics" id="lc-metrics">
18656        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18657        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18658        <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>
18659        <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>
18660      </div>
18661      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18662      <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>
18663      <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>
18664      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18665      <div class="lc-actions hidden" id="lc-actions">
18666        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18667        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18668      </div>
18669      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18670        <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>
18671        Cancel scan
18672      </button>
18673    </div>
18674  </div>
18675
18676  <div class="page">
18677    <div class="workbench-strip">
18678      <div class="workbench-box wb-stats">
18679        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18680          <span class="wb-stats-title">Analysis session</span>
18681        </div>
18682        <div class="ws-left">
18683          <div class="ws-stat ws-stat-analyzers">
18684            <span class="ws-label">Analyzers</span>
18685            <span class="ws-value">
18686              <span class="ws-badge">60 languages</span>
18687            </span>
18688            <div class="ws-lang-tooltip">
18689              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18690              <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>
18691              <div class="ws-lang-grid">
18692                <span class="ws-lang-item">Assembly</span>
18693                <span class="ws-lang-item">C</span>
18694                <span class="ws-lang-item">C++</span>
18695                <span class="ws-lang-item">C#</span>
18696                <span class="ws-lang-item">Clojure</span>
18697                <span class="ws-lang-item">CSS</span>
18698                <span class="ws-lang-item">Dart</span>
18699                <span class="ws-lang-item">Dockerfile</span>
18700                <span class="ws-lang-item">Elixir</span>
18701                <span class="ws-lang-item">Erlang</span>
18702                <span class="ws-lang-item">F#</span>
18703                <span class="ws-lang-item">Go</span>
18704                <span class="ws-lang-item">Groovy</span>
18705                <span class="ws-lang-item">Haskell</span>
18706                <span class="ws-lang-item">HTML</span>
18707                <span class="ws-lang-item">Java</span>
18708                <span class="ws-lang-item">JavaScript</span>
18709                <span class="ws-lang-item">Julia</span>
18710                <span class="ws-lang-item">Kotlin</span>
18711                <span class="ws-lang-item">Lua</span>
18712                <span class="ws-lang-item">Makefile</span>
18713                <span class="ws-lang-item">Nim</span>
18714                <span class="ws-lang-item">Obj-C</span>
18715                <span class="ws-lang-item">OCaml</span>
18716                <span class="ws-lang-item">Perl</span>
18717                <span class="ws-lang-item">PHP</span>
18718                <span class="ws-lang-item">PowerShell</span>
18719                <span class="ws-lang-item">Python</span>
18720                <span class="ws-lang-item">R</span>
18721                <span class="ws-lang-item">Ruby</span>
18722                <span class="ws-lang-item">Rust</span>
18723                <span class="ws-lang-item">Scala</span>
18724                <span class="ws-lang-item">SCSS</span>
18725                <span class="ws-lang-item">Shell</span>
18726                <span class="ws-lang-item">SQL</span>
18727                <span class="ws-lang-item">Svelte</span>
18728                <span class="ws-lang-item">Swift</span>
18729                <span class="ws-lang-item">TypeScript</span>
18730                <span class="ws-lang-item">Vue</span>
18731                <span class="ws-lang-item">XML</span>
18732                <span class="ws-lang-item">Zig</span>
18733                <span class="ws-lang-item">Solidity</span>
18734                <span class="ws-lang-item">Protobuf</span>
18735                <span class="ws-lang-item">HCL</span>
18736                <span class="ws-lang-item">GraphQL</span>
18737                <span class="ws-lang-item">Ada</span>
18738                <span class="ws-lang-item">VHDL</span>
18739                <span class="ws-lang-item">Verilog</span>
18740                <span class="ws-lang-item">Tcl</span>
18741                <span class="ws-lang-item">Pascal</span>
18742                <span class="ws-lang-item">Visual Basic</span>
18743                <span class="ws-lang-item">Lisp</span>
18744                <span class="ws-lang-item">Fortran</span>
18745                <span class="ws-lang-item">Nix</span>
18746                <span class="ws-lang-item">Crystal</span>
18747                <span class="ws-lang-item">D</span>
18748                <span class="ws-lang-item">GLSL</span>
18749                <span class="ws-lang-item">CMake</span>
18750                <span class="ws-lang-item">Elm</span>
18751                <span class="ws-lang-item">Awk</span>
18752              </div>
18753            </div>
18754          </div>
18755          <div class="ws-divider"></div>
18756          <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>
18757          <div class="ws-divider"></div>
18758          <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.">
18759            <span class="ws-label">Output</span>
18760            <span class="ws-value">
18761              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18762                <span id="ws-output-root">project/sloc</span>
18763              </button>
18764            </span>
18765          </div>
18766        </div>
18767      </div>
18768      <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.">
18769        <div class="ws-history-label">Scan history</div>
18770        <div class="ws-history-inner">
18771          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18772            <div class="ws-mini-label">Scans</div>
18773            <div class="ws-mini-value" id="ws-scan-count">—</div>
18774          </div>
18775          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18776            <div class="ws-mini-label">Last Scan</div>
18777            <div class="ws-mini-value" id="ws-last-scan">—</div>
18778          </div>
18779          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18780            <div class="ws-mini-label">Branch</div>
18781            <div class="ws-mini-value" id="ws-branch">—</div>
18782          </div>
18783        </div>
18784      </div>
18785    </div>
18786
18787    <div class="layout">
18788      <aside class="side-stack">
18789        <section class="step-nav">
18790        <h3>Guided scan setup</h3>
18791        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18792          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18793          Top of page
18794        </a>
18795        <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>
18796        <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>
18797        <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>
18798        <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>
18799
18800        <div class="step-steps-divider"></div>
18801
18802        <div class="step-nav-info" id="step-nav-info">
18803          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18804          <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>
18805        </div>
18806
18807        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18808          <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>
18809          <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>
18810          <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>
18811        </div>
18812
18813        <div class="quick-scan-divider"></div>
18814        <div class="quick-scan-section">
18815          <div class="quick-scan-label">No customization needed?</div>
18816          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18817            <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>
18818            Quick Scan
18819          </button>
18820          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18821        </div>
18822
18823        <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>
18824        <div class="sidebar-scroll-divider"></div>
18825        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18826          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18827          Skip to bottom
18828        </a>
18829        </section>
18830
18831      </aside>
18832
18833      <section class="card">
18834        <div class="card-header">
18835          <div class="card-title-row">
18836            <div>
18837              <h1 class="card-title">Guided scan configuration</h1>
18838              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18839            </div>
18840            <div class="wizard-progress" aria-label="Scan setup progress">
18841              <div class="wizard-progress-top">
18842                <span class="wizard-progress-label">Setup progress</span>
18843                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18844              </div>
18845              <div class="wizard-progress-track">
18846                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18847              </div>
18848            </div>
18849          </div>
18850        </div>
18851        <div class="card-body">
18852          <form method="post" action="/analyze" id="analyze-form">
18853            <div class="wizard-step active" data-step="1">
18854              <div class="section">
18855                <div class="section-kicker">Step 1</div>
18856                <h2>Select project and preview scope</h2>
18857                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18858                <div class="field">
18859                  <label for="path">Project path</label>
18860                  {% if !git_repo.is_empty() %}
18861                  <div class="git-source-banner">
18862                    <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>
18863                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18864                    <a href="/git-browser">← Back to Git Browser</a>
18865                  </div>
18866                  {% endif %}
18867                  <div class="path-scope-grid">
18868                      {% if !git_repo.is_empty() %}
18869                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18870                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18871                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18872                      {% else %}
18873                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18874                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18875                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18876                      {% endif %}
18877                    <div class="path-scope-sep"></div>
18878                    <div class="scope-legend-row">
18879                      <span class="scope-legend-label">Scope legend:</span>
18880                      <span class="scope-legend-badges">
18881                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18882                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18883                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18884                      </span>
18885                    </div>
18886                  </div>
18887                  {% if git_repo.is_empty() %}
18888                  {% if server_mode %}
18889                  <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18890                    ℹ️ Files are compressed and streamed — no fixed size limit.
18891                  </div>
18892                  {% endif %}
18893                  <div class="path-info-row">
18894                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18895                      <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>
18896                      <span id="project-size-text">Project size: —</span>
18897                    </button>
18898                  </div>
18899                  {% else %}
18900                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18901                  {% endif %}
18902                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18903                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18904                </div>
18905
18906                <div class="scope-preview-divider" aria-hidden="true"></div>
18907
18908                <div id="preview-panel">
18909                  <div class="preview-error">Loading preview...</div>
18910                </div>
18911              </div>
18912
18913              <div class="section" style="margin-top:14px;">
18914                <div class="preset-inline-row git-inline-row">
18915                  <div class="toggle-card" style="margin:0;">
18916                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18917                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18918                    <label class="checkbox">
18919                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18920                      <div>
18921                        <span>Detect and separate git submodules</span>
18922                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18923                      </div>
18924                    </label>
18925                  </div>
18926                  <div class="explainer-card prominent" style="margin:0;">
18927                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18928                    <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>
18929                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18930    path = libs/core
18931    url  = https://github.com/org/core.git
18932
18933[submodule "libs/ui"]
18934    path = libs/ui
18935    url  = https://github.com/org/ui.git</div>
18936                  </div>
18937                </div>
18938              </div>
18939
18940              <div class="section">
18941                <div class="field-grid">
18942                  <div class="field">
18943                    <div class="glob-label-row">
18944                      <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18945                      <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>
18946                    </div>
18947                    <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>
18948                    <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>
18949                  </div>
18950                  <div class="field">
18951                    <div class="glob-label-row">
18952                      <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18953                    </div>
18954                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
18955                    <div id="quick-exclude-chips" class="quick-excl-row">
18956                      <span class="quick-excl-label">Quick add:</span>
18957                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18958                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18959                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18960                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18961                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18962                      <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>
18963                    </div>
18964                    <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>
18965                  </div>
18966                </div>
18967                <div class="glob-guidance-grid">
18968                  <div class="glob-guidance-card">
18969                    <strong>How to read them</strong>
18970                    <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>
18971                  </div>
18972                  <div class="glob-guidance-card">
18973                    <strong>Common include examples</strong>
18974                    <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>
18975                  </div>
18976                  <div class="glob-guidance-card">
18977                    <strong>Common exclude examples</strong>
18978                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18979                  </div>
18980                </div>
18981              </div>
18982
18983              <div class="section" style="margin-top:14px;">
18984                <div class="preset-inline-row git-inline-row">
18985                  <div class="toggle-card" style="margin:0;">
18986                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18987                    <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>
18988                    <div class="field" style="margin:0;">
18989                      <div class="input-group compact">
18990                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18991                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18992                      </div>
18993                      <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>
18994                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18995                    </div>
18996                  </div>
18997                  <div class="explainer-card prominent" style="margin:0;">
18998                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18999                    <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>
19000                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19001lcov --capture --directory . --output-file coverage/lcov.info
19002
19003# C / C++ — llvm-cov (LCOV)
19004llvm-profdata merge -sparse default.profraw -o default.profdata
19005llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19006
19007# C# — coverlet (Cobertura XML)
19008dotnet test --collect:"XPlat Code Coverage"
19009
19010# Python — pytest-cov (Cobertura XML)
19011pytest --cov --cov-report=xml
19012
19013# Python — coverage.py native JSON
19014coverage run -m pytest && coverage json   # writes coverage.json
19015
19016# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19017./gradlew jacocoTestReport</div>
19018                  </div>
19019                </div>
19020              </div>
19021
19022              <div class="wizard-actions">
19023                <div class="left"></div>
19024                <div class="right">
19025                  <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19026                    <span class="preview-gate-spinner" aria-hidden="true"></span>
19027                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
19028                    <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">
19029                      <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>
19030                    </button>
19031                  </div>
19032                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19033                </div>
19034              </div>
19035            </div>
19036
19037            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19038              <div class="default-path-modal">
19039                <h3 id="default-path-title">
19040                  <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>
19041                  Proceed with the default sample test?
19042                </h3>
19043                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19044                <p>You haven&#39;t selected your own project yet.</p>
19045                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19046                <div class="default-path-actions">
19047                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19048                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19049                </div>
19050              </div>
19051            </div>
19052
19053            <div class="wizard-step" data-step="2">
19054              <div class="section">
19055                <div class="section-kicker">Step 2</div>
19056                <h2>Choose counting behavior</h2>
19057                <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>
19058<div class="subsection-bar">Primary line classification</div>
19059                <div class="preset-kv-row">
19060                  <div class="toggle-card mixed-line-card" style="margin:0;">
19061                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19062                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19063                    <select id="mixed_line_policy" name="mixed_line_policy">
19064                      <option value="code_only">Code only</option>
19065                      <option value="code_and_comment">Code and comment</option>
19066                      <option value="comment_only">Comment only</option>
19067                      <option value="separate_mixed_category">Separate mixed category</option>
19068                    </select>
19069                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19070                  </div>
19071                  <div class="explainer-card prominent" style="margin:0;">
19072                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19073                    <div class="explainer-body" id="mixed-policy-description"></div>
19074                    <div class="code-sample" id="mixed-policy-example"></div>
19075                  </div>
19076                </div>
19077              </div>
19078
19079              <div class="subsection-bar">Additional scan rules</div>
19080              <div class="scan-rules-grid">
19081                <div class="preset-inline-row">
19082                  <div class="toggle-card" style="margin:0;">
19083                    <div class="field-help-title">Generated files</div>
19084                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19085                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19086                  </div>
19087                  <div class="explainer-card prominent" style="margin:0;">
19088                    <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>
19089                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19090# Files matching codegen patterns are excluded:
19091#   *.generated.cs  *.pb.go  *.g.dart</div>
19092                  </div>
19093                </div>
19094                <div class="preset-inline-row">
19095                  <div class="toggle-card" style="margin:0;">
19096                    <div class="field-help-title">Minified files</div>
19097                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19098                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19099                  </div>
19100                  <div class="explainer-card prominent" style="margin:0;">
19101                    <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>
19102                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19103# Heuristic: very long lines + low whitespace ratio
19104#   jquery.min.js  bundle.min.css  → skipped</div>
19105                  </div>
19106                </div>
19107                <div class="preset-inline-row">
19108                  <div class="toggle-card" style="margin:0;">
19109                    <div class="field-help-title">Vendor directories</div>
19110                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19111                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19112                  </div>
19113                  <div class="explainer-card prominent" style="margin:0;">
19114                    <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>
19115                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19116# Directories named vendor/ node_modules/ third_party/
19117#   → entire subtree is excluded from totals</div>
19118                  </div>
19119                </div>
19120                <div class="preset-inline-row">
19121                  <div class="toggle-card" style="margin:0;">
19122                    <div class="field-help-title">Lockfiles and manifests</div>
19123                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19124                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19125                  </div>
19126                  <div class="explainer-card prominent" style="margin:0;">
19127                    <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>
19128                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
19129# Files like package-lock.json  Cargo.lock  yarn.lock
19130#   → skipped unless this is enabled</div>
19131                  </div>
19132                </div>
19133                <div class="preset-inline-row">
19134                  <div class="toggle-card" style="margin:0;">
19135                    <div class="field-help-title">Binary handling</div>
19136                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19137                    <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>
19138                  </div>
19139                  <div class="explainer-card prominent" style="margin:0;">
19140                    <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>
19141                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
19142# Detected via long lines + low whitespace heuristic
19143#   .png  .exe  .so  → skipped silently</div>
19144                  </div>
19145                </div>
19146                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19147                  <div class="toggle-card" style="margin:0;">
19148                    <div class="field-help-title">Python docstrings</div>
19149                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19150                    <label class="checkbox">
19151                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19152                      <span>Count as comment-style lines</span>
19153                    </label>
19154                  </div>
19155                  <div class="explainer-card prominent" style="margin:0;">
19156                    <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>
19157                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19158                  </div>
19159                </div>
19160              </div>
19161              <div class="subsection-bar">IEEE 1045-1992 counting</div>
19162              <div class="scan-rules-grid">
19163                <div class="preset-inline-row">
19164                  <div class="toggle-card" style="margin:0;">
19165                    <div class="field-help-title">Continuation lines</div>
19166                    <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19167                    <select name="continuation_line_policy" id="continuation_line_policy">
19168                      <option value="each_physical_line" selected>Each physical line (default)</option>
19169                      <option value="collapse_to_logical">Collapse to logical line</option>
19170                    </select>
19171                  </div>
19172                  <div class="explainer-card prominent" style="margin:0;">
19173                    <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>
19174                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19175    ((a) &gt; (b) ? (a) : (b))
19176# each_physical_line → 2 SLOC
19177# collapse_to_logical → 1 SLOC</div>
19178                  </div>
19179                </div>
19180                <div class="preset-inline-row">
19181                  <div class="toggle-card" style="margin:0;">
19182                    <div class="field-help-title">Block-comment blanks</div>
19183                    <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19184                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19185                      <option value="count_as_comment" selected>Count as comment (default)</option>
19186                      <option value="count_as_blank">Count as blank</option>
19187                    </select>
19188                  </div>
19189                  <div class="explainer-card prominent" style="margin:0;">
19190                    <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>
19191                    <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19192 * Summary line
19193 *              ← blank inside block comment
19194 * Detail line
19195 */
19196# count_as_comment → blank counts toward comments
19197# count_as_blank   → blank counts toward blanks</div>
19198                  </div>
19199                </div>
19200                <div class="preset-inline-row">
19201                  <div class="toggle-card" style="margin:0;">
19202                    <div class="field-help-title">Compiler directives</div>
19203                    <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19204                    <select name="count_compiler_directives" id="count_compiler_directives">
19205                      <option value="enabled" selected>Include in code SLOC (default)</option>
19206                      <option value="disabled">Exclude from code SLOC</option>
19207                    </select>
19208                  </div>
19209                  <div class="explainer-card prominent" style="margin:0;">
19210                    <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>
19211                    <div class="code-sample" style="margin-top:10px;font-size:12px;">#include &lt;stdio.h&gt;   ← compiler directive
19212#define BUF 256     ← compiler directive
19213int main() { … }   ← code
19214# enabled  → 3 code SLOC
19215# disabled → 1 code SLOC + 2 directive lines</div>
19216                  </div>
19217                </div>
19218              </div>
19219
19220              <div class="subsection-bar">Code Style Analysis</div>
19221              <div class="scan-rules-grid">
19222                <div class="preset-inline-row">
19223                  <div class="toggle-card" style="margin:0;">
19224                    <div class="field-help-title">Style analysis</div>
19225                    <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19226                    <select name="style_analysis_enabled" id="style_analysis_enabled">
19227                      <option value="enabled" selected>Enabled (default)</option>
19228                      <option value="disabled">Disabled — skip style scoring</option>
19229                    </select>
19230                  </div>
19231                  <div class="explainer-card prominent" style="margin:0;">
19232                    <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>
19233                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true   (default)
19234# style_analysis_enabled = false  (skip, faster scan)
19235# Disabling removes the Code Style section from the report.</div>
19236                  </div>
19237                </div>
19238                <div class="preset-inline-row">
19239                  <div class="toggle-card" style="margin:0;">
19240                    <div class="field-help-title">Column-width threshold</div>
19241                    <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19242                    <select name="style_col_threshold" id="style_col_threshold">
19243                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19244                      <option value="100">100 columns (Uber Go, Google Java)</option>
19245                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
19246                    </select>
19247                  </div>
19248                  <div class="explainer-card prominent" style="margin:0;">
19249                    <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>
19250                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80  (PEP 8, Google, gofmt)
19251# style_col_threshold = 100 (Uber Go, Google Java)
19252# style_col_threshold = 120 (Uber Go max, Kotlin)
19253# Files where &lt;= 5% of lines exceed the limit
19254# are counted as "N-col compliant" in the report.</div>
19255                  </div>
19256                </div>
19257                <div class="preset-inline-row">
19258                  <div class="toggle-card" style="margin:0;">
19259                    <div class="field-help-title">Score alert threshold</div>
19260                    <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19261                    <select name="style_score_threshold" id="style_score_threshold">
19262                      <option value="0" selected>Off — no threshold (default)</option>
19263                      <option value="40">40% — flag poorly styled files</option>
19264                      <option value="50">50% — flag below-average files</option>
19265                      <option value="60">60% — flag below-good files</option>
19266                      <option value="70">70% — flag below-strong files</option>
19267                    </select>
19268                  </div>
19269                  <div class="explainer-card prominent" style="margin:0;">
19270                    <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>
19271                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0   (off, default)
19272# style_score_threshold = 50  (flag files &lt; 50%)
19273# Low-scoring files get a red left-border in the
19274# per-file style breakdown table.</div>
19275                  </div>
19276                </div>
19277              </div>
19278
19279              <div class="always-tracked-tip">
19280                <div class="always-tracked-tip-icon">ℹ</div>
19281                <div class="always-tracked-tip-body">
19282                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
19283                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
19284                  <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>
19285                </div>
19286              </div>
19287
19288              <div class="subsection-bar">Advanced Metrics</div>
19289              <div class="scan-rules-grid">
19290                <div class="preset-inline-row">
19291                  <div class="toggle-card" style="margin:0;">
19292                    <div class="field-help-title">COCOMO mode</div>
19293                    <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19294                    <select name="cocomo_mode" id="cocomo_mode">
19295                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19296                      <option value="semi_detached">Semi-detached — mixed constraints</option>
19297                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
19298                    </select>
19299                  </div>
19300                  <div class="explainer-card prominent" style="margin:0;">
19301                    <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>
19302                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic:      Effort = 2.4 × KSLOC^1.05
19303# Semi-detached: Effort = 3.0 × KSLOC^1.12
19304# Embedded:     Effort = 3.6 × KSLOC^1.20
19305# All modes: Schedule = 2.5 × Effort^d</div>
19306                  </div>
19307                </div>
19308                <div class="preset-inline-row">
19309                  <div class="toggle-card" style="margin:0;">
19310                    <div class="field-help-title">Complexity alert</div>
19311                    <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19312                    <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;" />
19313                  </div>
19314                  <div class="explainer-card prominent" style="margin:0;">
19315                    <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>
19316                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19317# 50  = flag any file with &gt; 50 branch points
19318# 100 = flag any file with &gt; 100 branch points
19319# Files above the threshold are highlighted
19320# in the result page metric strip.</div>
19321                  </div>
19322                </div>
19323                <div class="preset-inline-row">
19324                  <div class="toggle-card" style="margin:0;">
19325                    <div class="field-help-title">Git hotspots</div>
19326                    <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19327                    <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;" />
19328                  </div>
19329                  <div class="explainer-card prominent" style="margin:0;">
19330                    <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>
19331                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90  = last quarter (default)
19332# 30  = last month of activity
19333# 365 = last year
19334# 0   = disable the hotspots table
19335# Adds Commits + Last-changed columns to CSV.</div>
19336                  </div>
19337                </div>
19338                <div class="preset-inline-row">
19339                  <div class="toggle-card" style="margin:0;">
19340                    <div class="field-help-title">Duplicate handling</div>
19341                    <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19342                    <select name="exclude_duplicates" id="exclude_duplicates">
19343                      <option value="disabled" selected>Detect and report only (default)</option>
19344                      <option value="enabled">Detect and exclude from SLOC totals</option>
19345                    </select>
19346                  </div>
19347                  <div class="explainer-card prominent" style="margin:0;">
19348                    <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>
19349                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19350# detect only   → all 3 counted in SLOC
19351# exclude dupes → 1 counted, 2 excluded
19352# Duplicate groups chip always shows the count.</div>
19353                  </div>
19354                </div>
19355                <div class="always-tracked-tip" style="margin:8px 0 0;">
19356                  <div class="always-tracked-tip-icon">ℹ</div>
19357                  <div class="always-tracked-tip-body">
19358                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
19359                    <div class="always-tracked-metrics-row">
19360                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19361                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
19362                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
19363                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
19364                    </div>
19365                    <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>
19366                  </div>
19367                </div>
19368              </div>
19369
19370              <div class="wizard-actions">
19371                <div class="left">
19372                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19373                </div>
19374                <div class="right">
19375                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19376                </div>
19377              </div>
19378            </div>
19379
19380            <div class="wizard-step" data-step="3">
19381              <div class="section">
19382                <div class="section-kicker">Step 3</div>
19383                <h2>Output and report identity</h2>
19384                <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>
19385                <div class="preset-kv-row">
19386                  <div class="toggle-card" style="margin:0;">
19387                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19388                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19389                    <select id="scan_preset">
19390                      <option value="balanced">Balanced local scan</option>
19391                      <option value="code_focused">Code focused</option>
19392                      <option value="comment_audit">Comment audit</option>
19393                      <option value="deep_review">Deep review</option>
19394                    </select>
19395                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19396                  </div>
19397                  <div class="explainer-card">
19398                    <div class="field-help-title">Selected scan preset</div>
19399                    <div class="explainer-body" id="scan-preset-description"></div>
19400                    <div class="preset-summary-row" id="scan-preset-summary"></div>
19401                    <div class="code-sample" id="scan-preset-example"></div>
19402                    <div class="preset-note" id="scan-preset-note"></div>
19403                  </div>
19404                </div>
19405                <hr class="step3-separator" />
19406                <div class="preset-kv-row">
19407                  <div class="toggle-card" style="margin:0;">
19408                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19409                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19410                    <select id="artifact_preset">
19411                      <option value="review">Review bundle</option>
19412                      <option value="full">Full bundle</option>
19413                      <option value="html_only">HTML only</option>
19414                      <option value="machine">Machine bundle</option>
19415                    </select>
19416                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19417                  </div>
19418                  <div class="explainer-card">
19419                    <div class="field-help-title">Selected artifact preset</div>
19420                    <div class="explainer-body" id="artifact-preset-description"></div>
19421                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
19422                    <div class="code-sample" id="artifact-preset-example"></div>
19423                  </div>
19424                </div>
19425              </div>
19426
19427              <div class="section section-spacer-top">
19428                <div class="output-field-row">
19429                  <div class="field">
19430                    <label for="output_dir">Output directory</label>
19431                    {% if server_mode %}
19432                    <div class="input-group compact">
19433                      <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);" />
19434                    </div>
19435                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19436                    {% else %}
19437                    <div class="input-group compact">
19438                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19439                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19440                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
19441                    </div>
19442                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19443                    {% endif %}
19444                  </div>
19445                  <div class="output-field-aside">
19446                    <strong>Where reports land</strong>
19447                    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.
19448                  </div>
19449                </div>
19450              </div>
19451
19452              <div class="section section-spacer-top">
19453                <div class="output-field-row">
19454                  <div class="field">
19455                    <label for="report_title">Report title</label>
19456                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19457                    <div class="hint">Appears in HTML and PDF output headers.</div>
19458                  </div>
19459                  <div class="output-field-aside">
19460                    <strong>Shown in exported artifacts</strong>
19461                    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.
19462                  </div>
19463                </div>
19464              </div>
19465
19466              <div class="section section-spacer-top">
19467                <div class="output-field-row">
19468                  <div class="field">
19469                    <label for="report_header_footer">Report header / footer</label>
19470                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19471                    <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>
19472                  </div>
19473                  <div class="output-field-aside">
19474                    <strong>Page-level identification</strong>
19475                    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.
19476                  </div>
19477                </div>
19478              </div>
19479
19480              <div class="wizard-actions">
19481                <div class="left">
19482                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19483                </div>
19484                <div class="right">
19485                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19486                </div>
19487              </div>
19488            </div>
19489
19490            <div class="wizard-step" data-step="4">
19491              <div class="section">
19492                <div class="section-kicker">Step 4</div>
19493                <h2>Review selections and run</h2>
19494                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19495                <div class="review-grid">
19496                  <div class="review-card highlight">
19497                    <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>
19498                    <ul id="review-scan-summary"></ul>
19499                  </div>
19500                  <div class="review-card highlight">
19501                    <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>
19502                    <ul id="review-count-summary"></ul>
19503                  </div>
19504                  <div class="review-card">
19505                    <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>
19506                    <ul id="review-artifact-summary"></ul>
19507                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19508                  </div>
19509                  <div class="review-card">
19510                    <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>
19511                    <ul id="review-preview-summary"></ul>
19512                  </div>
19513                </div>
19514              </div>
19515
19516              <div class="wizard-actions">
19517                <div class="left">
19518                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19519                </div>
19520                <div class="right">
19521                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
19522                </div>
19523              </div>
19524            </div>
19525            {% if server_mode %}
19526            <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19527            <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19528            {% endif %}
19529          </form>
19530        </div>
19531      </section>
19532    </div>
19533  </div>
19534
19535  <script nonce="{{ csp_nonce }}">
19536    (function () {
19537      function startScanPhase() {
19538        var phaseEl = document.getElementById("scan-phase");
19539        if (!phaseEl) return;
19540        var phases = [
19541          "Discovering files...",
19542          "Decoding file encodings...",
19543          "Detecting languages...",
19544          "Analyzing source lines...",
19545          "Applying counting policies...",
19546          "Aggregating results...",
19547          "Rendering report..."
19548        ];
19549        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19550        var i = 0;
19551        function next() {
19552          phaseEl.style.opacity = "0";
19553          setTimeout(function () {
19554            phaseEl.textContent = phases[i];
19555            phaseEl.style.opacity = "0.85";
19556            var delay = durations[i] || 1800;
19557            i++;
19558            if (i < phases.length) { setTimeout(next, delay); }
19559          }, 200);
19560        }
19561        next();
19562      }
19563
19564      var form = document.getElementById("analyze-form");
19565      var loading = document.getElementById("loading");
19566      var submitButton = document.getElementById("submit-button");
19567      var pathInput = document.getElementById("path");
19568      var GIT_MODE = !!(pathInput && pathInput.readOnly);
19569      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19570      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19571      var outputDirInput = document.getElementById("output_dir");
19572      var reportTitleInput = document.getElementById("report_title");
19573      var previewPanel = document.getElementById("preview-panel");
19574      var refreshButton = document.getElementById("refresh-preview");
19575      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19576      var useSamplePath = document.getElementById("use-sample-path");
19577      var useDefaultOutput = document.getElementById("use-default-output");
19578      var browsePath = document.getElementById("browse-path");
19579      var browseOutputDir = document.getElementById("browse-output-dir");
19580      var browseCoverage = document.getElementById("browse-coverage");
19581      var coverageInput = document.getElementById("coverage_file");
19582      var covScanStatus = document.getElementById("cov-scan-status");
19583      var coverageSuggestTimer = null;
19584      var covAutoFilled = false;
19585      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19586
19587      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19588      (function() {
19589        var ids = ["path", "output_dir"];
19590        ids.forEach(function(id) {
19591          var el = document.getElementById(id);
19592          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19593        });
19594      }());
19595      function fmtBytes(b) {
19596        b = Number(b) || 0;
19597        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19598        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19599        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
19600        return b + ' B';
19601      }
19602      var themeToggle = document.getElementById("theme-toggle");
19603
19604      function showBannerToast(msg, isError, opts) {
19605        opts = opts || {};
19606        var t = document.createElement('div');
19607        t.className = isError ? 'toast-error' : 'toast-success';
19608        var topPos = opts.top ? '80px' : null;
19609        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19610          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19611          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19612          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19613        if (opts.icon) {
19614          var inner = document.createElement('span');
19615          inner.innerHTML = opts.icon + ' ';
19616          t.appendChild(inner);
19617        }
19618        t.appendChild(document.createTextNode(msg));
19619        document.body.appendChild(t);
19620        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19621      }
19622      var mixedLinePolicy = document.getElementById("mixed_line_policy");
19623      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19624      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19625      var scanPreset = document.getElementById("scan_preset");
19626      var artifactPreset = document.getElementById("artifact_preset");
19627      var includeGlobsInput = document.getElementById("include_globs");
19628      var excludeGlobsInput = document.getElementById("exclude_globs");
19629
19630      // Include globs scope badge — updates reactively as the user types.
19631      (function() {
19632        var badge = document.getElementById("include-scope-badge");
19633        if (!badge || !includeGlobsInput) return;
19634        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> ';
19635        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> ';
19636        function update() {
19637          var val = includeGlobsInput.value.trim();
19638          if (!val) {
19639            badge.className = "include-scope-badge scope-all";
19640            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19641          } else {
19642            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19643            badge.className = "include-scope-badge scope-narrow";
19644            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19645          }
19646        }
19647        includeGlobsInput.addEventListener("input", update);
19648        update();
19649      }());
19650
19651      // Quick-exclude chips — append pattern to exclude_globs textarea.
19652      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19653        chip.addEventListener("click", function() {
19654          var pattern = chip.getAttribute("data-pattern") || "";
19655          if (!pattern || !excludeGlobsInput) return;
19656          var current = excludeGlobsInput.value.trim();
19657          // For the "skip all" chip, replace any existing dep patterns cleanly.
19658          var patterns = pattern.split("\n");
19659          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19660          var added = false;
19661          patterns.forEach(function(p) {
19662            p = p.trim();
19663            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19664          });
19665          if (added) {
19666            excludeGlobsInput.value = lines.join("\n");
19667            excludeGlobsInput.dispatchEvent(new Event("input"));
19668          }
19669          chip.classList.add("active");
19670        });
19671      });
19672
19673      var liveReportTitle = document.getElementById("live-report-title");
19674      var navProjectPill = document.getElementById("nav-project-pill");
19675      var navProjectTitle = document.getElementById("nav-project-title");
19676      var reportTitlePreview = null;
19677      var wizardProgressFill = document.getElementById("wizard-progress-fill");
19678      var wizardProgressValue = document.getElementById("wizard-progress-value");
19679      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19680      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19681      var reportTitleTouched = false;
19682      var currentStep = 1;
19683      var previewTimer = null;
19684      var _previewGen = 0;
19685      // True while the scope preview (local) / project upload (server mode) is in
19686      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19687      // user can't advance past a project whose scope/upload isn't ready yet.
19688      var previewLoading = false;
19689      // Set when the current preview reports multiple independent git repos under
19690      // the selected root. Advancing past step 1 is blocked until the user ticks
19691      // the acknowledgement checkbox (or re-selects a single repository).
19692      var multiRepoBlocked = false;
19693      function step1ForwardBlocked() {
19694        return previewLoading || multiRepoBlocked;
19695      }
19696      function refreshStep1Gate() {
19697        var nextBtn = document.getElementById("step1-next");
19698        if (nextBtn) {
19699          var blocked = step1ForwardBlocked();
19700          nextBtn.classList.toggle("is-blocked", blocked);
19701          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19702        }
19703      }
19704      function setPreviewLoading(loading) {
19705        previewLoading = !!loading;
19706        var gate = document.getElementById("preview-gate-status");
19707        refreshStep1Gate();
19708        if (gate) {
19709          var txt = gate.querySelector(".preview-gate-text");
19710          if (txt) txt.textContent = SERVER_MODE
19711            ? "Uploading & scanning project…"
19712            : "Scanning project scope…";
19713          gate.style.display = previewLoading ? "flex" : "none";
19714        }
19715      }
19716      // Info button on the gate: scroll up to the live scope preview so the user
19717      // can see exactly what is being scanned (elapsed time + rotating status).
19718      var previewGateInfo = document.getElementById("preview-gate-info");
19719      if (previewGateInfo) {
19720        previewGateInfo.addEventListener("click", function () {
19721          var target = document.getElementById("preview-panel");
19722          if (!target) return;
19723          target.scrollIntoView({ behavior: "smooth", block: "center" });
19724          target.classList.add("preview-panel-flash");
19725          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19726        });
19727      }
19728      var quickScanBtn = document.getElementById("quick-scan-btn");
19729
19730      function dismissAnalysisModal() {
19731        if (loading) loading.classList.remove("active");
19732        document.body.classList.remove("modal-open");
19733        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19734          var el = document.getElementById(id);
19735          if (el) el.classList.add("hidden");
19736        });
19737        var cancelBtn = document.getElementById("lc-cancel-btn");
19738        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19739        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19740        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19741        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19742        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");}
19743        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19744        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19745        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19746        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19747        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19748        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19749      }
19750
19751      var lcDismissBtn = document.getElementById("lc-dismiss");
19752      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19753
19754      // When the browser restores this page from bfcache (Back button after navigating to results),
19755      // the loading overlay would still be showing its active state. Dismiss it immediately.
19756      window.addEventListener("pageshow", function(e) {
19757        if (e.persisted) { dismissAnalysisModal(); }
19758      });
19759
19760      function startAsyncAnalysis(formData) {
19761        var gitRepo = (formData.get("git_repo") || "").toString();
19762        var gitRef  = (formData.get("git_ref")  || "").toString();
19763        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19764        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19765
19766        var pathEl = document.getElementById("lc-path-text");
19767        if (pathEl) pathEl.textContent = displayPath;
19768
19769        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19770          var el = document.getElementById(id);
19771          if (el) el.classList.add("hidden");
19772        });
19773        var cancelBtn = document.getElementById("lc-cancel-btn");
19774        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19775        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19776        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19777        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19778        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19779        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
19780        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19781        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");}
19782        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19783
19784        if (loading) loading.classList.add("active");
19785        document.body.classList.add("modal-open");
19786
19787        var startTime = Date.now();
19788        var elapsedTimer = setInterval(function() {
19789          var s = Math.floor((Date.now() - startTime) / 1000);
19790          var el = document.getElementById("lc-elapsed");
19791          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19792        }, 1000);
19793
19794        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19795
19796        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();}
19797
19798        var PHASE_DESC = {
19799          'Starting': 'Initializing language analyzers and loading configuration\u2026',
19800          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19801          'Running': 'Running the lexical state machine across all discovered source files\u2026',
19802          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19803          'Done': 'Analysis complete \u2014 loading your results\u2026',
19804          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19805        };
19806        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19807        function lcSetPhase(txt) {
19808          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19809          var desc = document.getElementById("lc-stage-desc");
19810          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19811          var step = PHASE_STEP[txt] || 1;
19812          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");}
19813        }
19814
19815        function lcShowCancelled() {
19816          clearInterval(elapsedTimer);
19817          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19818          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19819          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19820          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19821          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19822          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19823          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19824          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19825          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19826          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19827        }
19828
19829        var lcCancelBtn = document.getElementById("lc-cancel-btn");
19830        if (lcCancelBtn) {
19831          lcCancelBtn.onclick = function() {
19832            if (!activeWaitId) { dismissAnalysisModal(); return; }
19833            lcCancelBtn.disabled = true;
19834            lcCancelBtn.textContent = "Cancelling\u2026";
19835            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19836              .then(function() { lcShowCancelled(); })
19837              .catch(function() { lcShowCancelled(); });
19838          };
19839        }
19840
19841        function lcShowError(msg) {
19842          clearInterval(elapsedTimer);
19843          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19844          lcSetPhase("Failed");
19845          var msgEl = document.getElementById("lc-err-msg");
19846          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19847          var errEl = document.getElementById("lc-err");
19848          var actEl = document.getElementById("lc-actions");
19849          if (errEl) errEl.classList.remove("hidden");
19850          if (actEl) actEl.classList.remove("hidden");
19851          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19852          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19853        }
19854
19855        function lcPoll(waitId) {
19856          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19857            .then(function(r) {
19858              if (!r.ok) throw new Error("HTTP " + r.status);
19859              return r.json();
19860            })
19861            .then(function(data) {
19862              pollRetries = 0;
19863              if (data.state === "complete") {
19864                clearInterval(elapsedTimer);
19865                lcSetPhase("Done");
19866                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19867              } else if (data.state === "failed") {
19868                lcShowError(data.message);
19869              } else if (data.state === "cancelled") {
19870                lcShowCancelled();
19871              } else {
19872                var s = Math.floor((Date.now() - startTime) / 1000);
19873                if (s > 90 && !warnShown) {
19874                  warnShown = true;
19875                  var w = document.getElementById("lc-warn");
19876                  if (w) w.classList.remove("hidden");
19877                }
19878                lcSetPhase(data.phase || "Running");
19879                var fd = data.files_done || 0, ft = data.files_total || 0;
19880                if (ft > 0) {
19881                  var card = document.getElementById("lc-files-card");
19882                  if (card) card.classList.remove("hidden");
19883                  var el = document.getElementById("lc-files");
19884                  if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19885                  var now = Date.now();
19886                  var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19887                  if (fdelta > 0 && tdelta > 0.4) {
19888                    var fps = Math.round(fdelta / tdelta);
19889                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19890                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19891                  }
19892                  lastFd = fd; lastFdTime = now;
19893                }
19894                setTimeout(function() { lcPoll(waitId); }, 1500);
19895              }
19896            })
19897            .catch(function() {
19898              pollRetries++;
19899              if (pollRetries >= 5) {
19900                lcShowError("Lost connection to server. Reload to check status.");
19901              } else {
19902                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19903              }
19904            });
19905        }
19906
19907        var params = new URLSearchParams(formData);
19908        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19909          .then(function(r) {
19910            var waitId = r.headers.get("x-wait-id");
19911            if (!waitId) { window.location.href = "/scan"; return; }
19912            activeWaitId = waitId;
19913            setTimeout(function() { lcPoll(waitId); }, 1500);
19914          })
19915          .catch(function(err) {
19916            lcShowError("Could not reach server: " + (err.message || err));
19917          });
19918      }
19919
19920      if (quickScanBtn) {
19921        quickScanBtn.addEventListener("click", function () {
19922          var pathVal = pathInput ? pathInput.value.trim() : "";
19923          if (!pathVal) {
19924            alert("Please enter or browse to a project path first.");
19925            return;
19926          }
19927          quickScanBtn.disabled = true;
19928          quickScanBtn.textContent = "Scanning...";
19929          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19930          startAsyncAnalysis(new FormData(form));
19931        });
19932      }
19933
19934      var mixedPolicyInfo = {
19935        code_only: {
19936          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.",
19937          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'
19938        },
19939        code_and_comment: {
19940          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.",
19941          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'
19942        },
19943        comment_only: {
19944          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.",
19945          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'
19946        },
19947        separate_mixed_category: {
19948          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.",
19949          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'
19950        }
19951      };
19952
19953      var scanPresetInfo = {
19954        balanced: {
19955          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.",
19956          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19957          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19958          note: "Best when you want a stable local overview before making deeper adjustments.",
19959          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19960        },
19961        code_focused: {
19962          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19963          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19964          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19965          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19966          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19967        },
19968        comment_audit: {
19969          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19970          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19971          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19972          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19973          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19974        },
19975        deep_review: {
19976          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19977          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19978          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19979          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19980          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19981        }
19982      };
19983
19984      var artifactPresetInfo = {
19985        review: {
19986          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19987          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19988          example: "Ideal for a quick local review before sharing results."
19989        },
19990        full: {
19991          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19992          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19993          example: "Use when producing a deliverable or storing a snapshot for future comparison."
19994        },
19995        html_only: {
19996          description: "Standalone HTML report only. No PDF generation, no data files.",
19997          chips: ["HTML only"],
19998          example: "Fastest option when you only need to open the report in a browser."
19999        },
20000        machine: {
20001          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20002          chips: ["JSON", "CSV", "no HTML", "no PDF"],
20003          example: "Use in CI to capture metrics without generating visual reports."
20004        }
20005      };
20006
20007      function applyArtifactPreset() {
20008        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20009        if (!info) return;
20010        var descEl = document.getElementById("artifact-preset-description");
20011        var exampleEl = document.getElementById("artifact-preset-example");
20012        if (descEl) descEl.textContent = info.description;
20013        if (exampleEl) exampleEl.textContent = info.example;
20014        renderPresetChips("artifact-preset-summary", info.chips);
20015      }
20016
20017      function applyTheme(theme) {
20018        if (theme === "dark") document.body.classList.add("dark-theme");
20019        else document.body.classList.remove("dark-theme");
20020      }
20021
20022      function loadSavedTheme() {
20023        var saved = null;
20024        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20025        applyTheme(saved === "dark" ? "dark" : "light");
20026      }
20027
20028      function updateScrollProgress() {
20029        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20030        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20031        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
20032        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20033        var step = Math.min(Math.max(currentStep, 1), 4);
20034        var base = stepBase[step];
20035        var end  = stepEnd[step];
20036
20037        var scrollFrac = 0;
20038        var activePanel = document.querySelector(".wizard-step.active");
20039        if (activePanel) {
20040          var scrollTop = window.scrollY || window.pageYOffset || 0;
20041          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20042          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20043          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20044          var scrolled = scrollTop + viewH - panelTop;
20045          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20046        }
20047
20048        var percent = Math.round(base + (end - base) * scrollFrac);
20049        percent = Math.min(end, Math.max(base, percent));
20050        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20051        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20052      }
20053
20054      function updateWizardProgress() {
20055        updateScrollProgress();
20056      }
20057
20058      var stepDescriptions = [
20059        "Choose a project folder, apply scope filters, and preview which files will be counted.",
20060        "Configure how mixed code-plus-comment lines and docstrings are classified.",
20061        "Pick your output formats, scan preset, and where reports are saved.",
20062        "Review all settings and launch the analysis."
20063      ];
20064
20065      function updateStepNav(step) {
20066        var infoLabel = document.getElementById("step-nav-info-label");
20067        var infoDesc  = document.getElementById("step-nav-info-desc");
20068        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20069        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
20070      }
20071
20072      function updateSidebarSummary() {
20073        var sumPath    = document.getElementById("sum-path");
20074        var sumPreset  = document.getElementById("sum-preset");
20075        var sumOutput  = document.getElementById("sum-output");
20076        var sidebarSummary = document.getElementById("sidebar-summary");
20077        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20078        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
20079        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20080        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
20081        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20082        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20083        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20084      }
20085
20086      function setStep(step, pushHistory) {
20087        currentStep = step;
20088        stepPanels.forEach(function (panel) {
20089          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20090        });
20091        stepButtons.forEach(function (button) {
20092          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20093        });
20094        var layoutEl = document.querySelector(".layout");
20095        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20096        updateWizardProgress();
20097        updateStepNav(step);
20098        stepButtons.forEach(function(btn) {
20099          var t = Number(btn.getAttribute("data-step-target"));
20100          btn.classList.toggle("done", t < step);
20101        });
20102        updateSidebarSummary();
20103
20104        if (pushHistory !== false) {
20105          try {
20106            history.pushState({ wizardStep: step }, "", "#step" + step);
20107          } catch (e) {}
20108        }
20109
20110        window.scrollTo({ top: 0, behavior: "instant" });
20111      }
20112
20113      window.addEventListener("popstate", function (e) {
20114        if (e.state && e.state.wizardStep) {
20115          setStep(e.state.wizardStep, false);
20116        } else {
20117          var hashMatch = location.hash.match(/^#step([1-4])$/);
20118          if (hashMatch) setStep(Number(hashMatch[1]), false);
20119        }
20120      });
20121
20122      function inferTitleFromPath(value) {
20123        if (!value) return "project";
20124        var cleaned = value.replace(/[\/\\]+$/, "");
20125        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20126        return parts.length ? parts[parts.length - 1] : value;
20127      }
20128
20129      function updateReportTitleFromPath() {
20130        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20131        if (!reportTitleTouched) {
20132          reportTitleInput.value = inferred;
20133        }
20134        var title = reportTitleInput.value || inferred;
20135        if (liveReportTitle) liveReportTitle.textContent = title;
20136        if (reportTitlePreview) reportTitlePreview.textContent = title;
20137        document.title = "OxideSLOC | " + title;
20138
20139        var projectPath = (pathInput.value || "").trim();
20140        if (navProjectPill && navProjectTitle) {
20141          if (projectPath.length > 0) {
20142            navProjectTitle.textContent = inferred;
20143            navProjectPill.classList.add("visible");
20144          } else {
20145            navProjectTitle.textContent = "";
20146            navProjectPill.classList.remove("visible");
20147          }
20148        }
20149      }
20150
20151      function updateMixedPolicyUI() {
20152        var key = mixedLinePolicy.value || "code_only";
20153        var info = mixedPolicyInfo[key];
20154        document.getElementById("mixed-policy-description").textContent = info.description;
20155        document.getElementById("mixed-policy-example").textContent = info.example;
20156      }
20157
20158      function updatePythonDocstringUI() {
20159        var checked = !!pythonDocstrings.checked;
20160        document.getElementById("python-docstring-example").textContent = checked
20161          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
20162          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
20163        document.getElementById("python-docstring-live-help").textContent = checked
20164          ? "Enabled: docstrings contribute to comment-style totals."
20165          : "Disabled: docstrings are not counted as comment content.";
20166      }
20167
20168      function renderPresetChips(targetId, chips) {
20169        var target = document.getElementById(targetId);
20170        if (!target) return;
20171        target.innerHTML = (chips || []).map(function (chip) {
20172          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20173        }).join('');
20174      }
20175
20176      function updatePresetDescriptions() {
20177        var scanInfo = scanPresetInfo[scanPreset.value];
20178        if (!scanInfo) return;
20179        document.getElementById("scan-preset-description").textContent = scanInfo.description;
20180        document.getElementById("scan-preset-example").textContent = scanInfo.example;
20181        document.getElementById("scan-preset-note").textContent = scanInfo.note;
20182        renderPresetChips("scan-preset-summary", scanInfo.chips);
20183      }
20184
20185      function applyScanPreset() {
20186        var info = scanPresetInfo[scanPreset.value];
20187        if (!info || !info.apply) return;
20188        mixedLinePolicy.value = info.apply.mixed;
20189        pythonDocstrings.checked = !!info.apply.docstrings;
20190        document.getElementById("generated_file_detection").value = info.apply.generated;
20191        document.getElementById("minified_file_detection").value = info.apply.minified;
20192        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20193        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20194        document.getElementById("binary_file_behavior").value = info.apply.binary;
20195        updateMixedPolicyUI();
20196        updatePythonDocstringUI();
20197      }
20198
20199      function updateReview() {
20200        var scanSummary = document.getElementById("review-scan-summary");
20201        var countSummary = document.getElementById("review-count-summary");
20202        var artifactSummary = document.getElementById("review-artifact-summary");
20203        var outputSummary = document.getElementById("review-output-summary");
20204        var previewSummary = document.getElementById("review-preview-summary");
20205        var readinessSummary = document.getElementById("review-readiness-summary");
20206        var includeText = document.getElementById("include_globs").value.trim();
20207        var excludeText = document.getElementById("exclude_globs").value.trim();
20208        var sidePathPreview = document.getElementById("side-path-preview");
20209        var sideOutputPreview = document.getElementById("side-output-preview");
20210        var sideTitlePreview = document.getElementById("side-title-preview");
20211
20212        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20213        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20214        if (sideTitlePreview) {
20215          var rt = document.getElementById("report_title");
20216          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20217        }
20218
20219        scanSummary.innerHTML = ""
20220          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20221          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20222          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20223
20224        countSummary.innerHTML = ""
20225          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20226          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20227          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20228          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20229          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20230          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20231          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20232          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20233
20234        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20235
20236        outputSummary.innerHTML = ""
20237          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20238          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20239
20240        if (previewSummary) {
20241          if (GIT_MODE) {
20242            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>';
20243          } else {
20244          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20245          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20246          var statMap = {};
20247          statButtons.forEach(function (button) {
20248            var valueNode = button.querySelector('.scope-stat-value');
20249            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20250          });
20251          previewSummary.innerHTML = ''
20252            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20253            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20254            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20255            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20256            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20257            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20258
20259          if (readinessSummary) {
20260            readinessSummary.innerHTML = ''
20261              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20262              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20263              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20264          }
20265          } // end else (non-GIT_MODE)
20266        }
20267      }
20268
20269      function escapeHtml(value) {
20270        return String(value)
20271          .replace(/&/g, "&amp;")
20272          .replace(/</g, "&lt;")
20273          .replace(/>/g, "&gt;")
20274          .replace(/"/g, "&quot;")
20275          .replace(/'/g, "&#39;");
20276      }
20277
20278      function isPythonVisible() {
20279        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20280      }
20281
20282      function syncPythonVisibility() {
20283        var html = previewPanel.textContent || "";
20284        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20285        pythonWraps.forEach(function (node) {
20286          node.classList.toggle("hidden", !hasPython);
20287        });
20288      }
20289
20290      function attachPreviewInteractions() {
20291        // Multiple-repository caution banner: gate step 1 until acknowledged, and
20292        // let each listed repo be picked as the scan root with one click.
20293        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20294        if (multiRepoBanner) {
20295          multiRepoBlocked = true;
20296          refreshStep1Gate();
20297          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20298          if (ackBox) {
20299            ackBox.addEventListener("change", function () {
20300              multiRepoBlocked = !ackBox.checked;
20301              refreshStep1Gate();
20302            });
20303          }
20304          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20305          repoButtons.forEach(function (btn) {
20306            btn.addEventListener("click", function () {
20307              var repoPath = btn.getAttribute("data-repo-path") || "";
20308              if (!repoPath || !pathInput) return;
20309              pathInput.value = repoPath;
20310              scrollInputToEnd(pathInput);
20311              updateReportTitleFromPath();
20312              autoSetOutputDir(repoPath);
20313              fetchProjectHistory(repoPath);
20314              loadPreview();
20315              updateReview();
20316            });
20317          });
20318        }
20319        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20320        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20321        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20322        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20323        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20324        var searchInput = previewPanel.querySelector("#explorer-search");
20325        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20326        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20327        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20328        var activeFilter = "all";
20329        var activeLanguage = "";
20330        var searchTerm = "";
20331        var currentSortKey = null;
20332        var currentSortOrder = "asc";
20333        var childRows = {};
20334
20335        rows.forEach(function (row) {
20336          var parentId = row.getAttribute("data-parent-id") || "";
20337          var rowId = row.getAttribute("data-row-id") || "";
20338          if (!childRows[parentId]) childRows[parentId] = [];
20339          childRows[parentId].push(rowId);
20340        });
20341
20342        function rowById(id) {
20343          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20344        }
20345
20346        function hasCollapsedAncestor(row) {
20347          var parentId = row.getAttribute("data-parent-id");
20348          while (parentId) {
20349            var parent = rowById(parentId);
20350            if (!parent) break;
20351            if (parent.getAttribute("data-expanded") === "false") return true;
20352            parentId = parent.getAttribute("data-parent-id");
20353          }
20354          return false;
20355        }
20356
20357        function updateToggleGlyph(row) {
20358          var toggle = row.querySelector(".tree-toggle");
20359          if (!toggle) return;
20360          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20361        }
20362
20363        function rowSortValue(row, key) {
20364          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20365        }
20366
20367        function updateSortButtons() {
20368          sortButtons.forEach(function (button) {
20369            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20370            var indicator = button.querySelector(".tree-sort-indicator");
20371            button.classList.toggle("active", isActive);
20372            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20373            if (indicator) {
20374              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20375            }
20376          });
20377        }
20378
20379        function sortSiblingRows() {
20380          if (!treeContainer) {
20381            updateSortButtons();
20382            return;
20383          }
20384
20385          var rowMap = {};
20386          var childrenMap = {};
20387          rows.forEach(function (row) {
20388            var rowId = row.getAttribute("data-row-id");
20389            var parentId = row.getAttribute("data-parent-id") || "";
20390            rowMap[rowId] = row;
20391            if (!childrenMap[parentId]) childrenMap[parentId] = [];
20392            childrenMap[parentId].push(rowId);
20393          });
20394
20395          Object.keys(childrenMap).forEach(function (parentId) {
20396            if (!parentId) return;
20397            childrenMap[parentId].sort(function (a, b) {
20398              var rowA = rowMap[a];
20399              var rowB = rowMap[b];
20400              if (!currentSortKey) {
20401                return Number(a) - Number(b);
20402              }
20403              var valueA = rowSortValue(rowA, currentSortKey);
20404              var valueB = rowSortValue(rowB, currentSortKey);
20405              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20406              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20407              var fallbackA = rowSortValue(rowA, "name");
20408              var fallbackB = rowSortValue(rowB, "name");
20409              if (fallbackA < fallbackB) return -1;
20410              if (fallbackA > fallbackB) return 1;
20411              return Number(a) - Number(b);
20412            });
20413          });
20414
20415          var orderedIds = [];
20416          function pushChildren(parentId) {
20417            (childrenMap[parentId] || []).forEach(function (childId) {
20418              orderedIds.push(childId);
20419              pushChildren(childId);
20420            });
20421          }
20422
20423          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20424            orderedIds.push(topId);
20425            pushChildren(topId);
20426          });
20427
20428          orderedIds.forEach(function (id) {
20429            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20430          });
20431          updateSortButtons();
20432        }
20433
20434        function updateLanguageButtons() {
20435          languageButtons.forEach(function (button) {
20436            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20437            var isActive = languageValue === activeLanguage;
20438            button.classList.toggle("active", isActive);
20439          });
20440        }
20441
20442        function rowSelfMatches(row) {
20443          var kind = row.getAttribute("data-kind");
20444          var status = row.getAttribute("data-status");
20445          var language = (row.getAttribute("data-language") || "").toLowerCase();
20446          var name = row.getAttribute("data-name-lower") || "";
20447          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20448          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20449          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20450          var passesLanguage = !activeLanguage || language === activeLanguage;
20451          return passesFilter && passesSearch && passesLanguage;
20452        }
20453
20454        function hasMatchingDescendant(rowId) {
20455          return (childRows[rowId] || []).some(function (childId) {
20456            var childRow = rowById(childId);
20457            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20458          });
20459        }
20460
20461        function rowMatches(row) {
20462          if (rowSelfMatches(row)) return true;
20463          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20464        }
20465
20466        function resetViewState() {
20467          activeFilter = "all";
20468          activeLanguage = "";
20469          searchTerm = "";
20470          currentSortKey = null;
20471          currentSortOrder = "asc";
20472          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20473          if (searchInput) searchInput.value = "";
20474          if (filterSelect) filterSelect.value = "all";
20475          updateLanguageButtons();
20476        }
20477
20478        function applyVisibility() {
20479          rows.forEach(function (row) {
20480            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20481            row.classList.toggle("hidden-by-filter", !visible);
20482            row.style.display = visible ? "grid" : "none";
20483          });
20484          buttons.forEach(function (button) {
20485            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20486          });
20487          if (filterSelect) filterSelect.value = activeFilter;
20488        }
20489
20490        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20491        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20492        var originalStats = {};
20493        buttons.forEach(function (btn) {
20494          var f = btn.getAttribute('data-filter');
20495          var v = btn.querySelector('.scope-stat-value');
20496          if (f && v) originalStats[f] = v.textContent;
20497        });
20498
20499        function applySubmoduleStats(statsJson) {
20500          try {
20501            var s = JSON.parse(statsJson);
20502            buttons.forEach(function (btn) {
20503              var f = btn.getAttribute('data-filter');
20504              var v = btn.querySelector('.scope-stat-value');
20505              if (!v) return;
20506              if (f === 'dir') v.textContent = s.dirs;
20507              else if (f === 'file') v.textContent = s.files;
20508              else if (f === 'supported') v.textContent = s.supported;
20509              else if (f === 'skipped') v.textContent = s.skipped;
20510              else if (f === 'unsupported') v.textContent = s.unsupported;
20511            });
20512          } catch (e) {}
20513        }
20514
20515        function restoreBaseRepoStats() {
20516          buttons.forEach(function (btn) {
20517            var f = btn.getAttribute('data-filter');
20518            var v = btn.querySelector('.scope-stat-value');
20519            if (v && originalStats[f]) v.textContent = originalStats[f];
20520          });
20521          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20522          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20523        }
20524
20525        submoduleChips.forEach(function (chip) {
20526          chip.addEventListener('click', function () {
20527            var statsJson = chip.getAttribute('data-sub-stats');
20528            if (!statsJson) return;
20529            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20530            chip.classList.add('active');
20531            applySubmoduleStats(statsJson);
20532            if (baseRepoBtn) baseRepoBtn.style.display = '';
20533          });
20534        });
20535
20536        if (baseRepoBtn) {
20537          baseRepoBtn.addEventListener('click', function () {
20538            restoreBaseRepoStats();
20539            resetViewState();
20540            sortSiblingRows();
20541            applyVisibility();
20542          });
20543        }
20544
20545        buttons.forEach(function (button) {
20546          button.addEventListener("click", function () {
20547            var filterValue = button.getAttribute("data-filter") || "all";
20548            if (filterValue === "reset-view") {
20549              restoreBaseRepoStats();
20550              resetViewState();
20551              sortSiblingRows();
20552              applyVisibility();
20553              return;
20554            }
20555            activeFilter = filterValue;
20556            applyVisibility();
20557          });
20558        });
20559
20560        rows.forEach(function (row) {
20561          updateToggleGlyph(row);
20562          var toggle = row.querySelector(".tree-toggle");
20563          if (toggle) {
20564            toggle.addEventListener("click", function () {
20565              var expanded = row.getAttribute("data-expanded") !== "false";
20566              row.setAttribute("data-expanded", expanded ? "false" : "true");
20567              updateToggleGlyph(row);
20568              applyVisibility();
20569            });
20570          }
20571        });
20572
20573        actionButtons.forEach(function (button) {
20574          button.addEventListener("click", function () {
20575            var action = button.getAttribute("data-explorer-action");
20576            if (action === "expand-all") {
20577              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20578            } else if (action === "collapse-all") {
20579              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20580            } else if (action === "clear-filters") {
20581              resetViewState();
20582            }
20583            sortSiblingRows();
20584            applyVisibility();
20585          });
20586        });
20587
20588        if (filterSelect) {
20589          filterSelect.addEventListener("change", function () {
20590            activeFilter = filterSelect.value || "all";
20591            applyVisibility();
20592          });
20593        }
20594
20595        languageButtons.forEach(function (button) {
20596          button.addEventListener("click", function () {
20597            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20598            updateLanguageButtons();
20599            applyVisibility();
20600          });
20601        });
20602
20603        sortButtons.forEach(function (button) {
20604          button.addEventListener("click", function () {
20605            var sortKey = button.getAttribute("data-sort-key");
20606            if (currentSortKey === sortKey) {
20607              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20608            } else {
20609              currentSortKey = sortKey;
20610              currentSortOrder = "asc";
20611            }
20612            sortSiblingRows();
20613            applyVisibility();
20614          });
20615        });
20616
20617        if (searchInput) {
20618          searchInput.addEventListener("input", function () {
20619            searchTerm = searchInput.value.trim().toLowerCase();
20620            applyVisibility();
20621          });
20622        }
20623
20624        updateLanguageButtons();
20625        sortSiblingRows();
20626        applyVisibility();
20627      }
20628
20629      function loadPreview() {
20630        if (!previewPanel || !pathInput) return;
20631        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20632        multiRepoBlocked = false;
20633        refreshStep1Gate();
20634        if (GIT_MODE) {
20635          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>';
20636          setPreviewLoading(false);
20637          return;
20638        }
20639        var path = pathInput.value.trim();
20640        var zeroWarn = document.getElementById('zero-files-warning');
20641        if (!path) {
20642          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20643          if (zeroWarn) zeroWarn.style.display = 'none';
20644          setPreviewLoading(false);
20645          return;
20646        }
20647        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20648        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20649        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20650        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20651        var myGen = ++_previewGen;
20652        var _prevMsgs = [
20653          'Scanning directory structure\u2026',
20654          'Detecting file types\u2026',
20655          'Applying include / exclude filters\u2026',
20656          'Estimating file counts\u2026',
20657          'Building scope preview\u2026',
20658          'Almost there\u2026'
20659        ];
20660        var _prevMsgIdx = 0;
20661        var _prevStart = Date.now();
20662        previewPanel.innerHTML =
20663          '<div class="preview-loading">' +
20664          '<div class="preview-spinner"></div>' +
20665          '<div class="preview-loading-text">' +
20666          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20667          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20668          '</div></div>';
20669        var _sizeTextEl = document.getElementById('project-size-text');
20670        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20671        window._previewInterval = setInterval(function() {
20672          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20673          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20674          var ml = document.getElementById('plm');
20675          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20676        }, 1500);
20677        window._previewElapsedTimer = setInterval(function() {
20678          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20679          var el = document.getElementById('ple');
20680          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20681        }, 1000);
20682        setPreviewLoading(true);
20683        var previewUrl = "/preview?path=" + encodeURIComponent(path)
20684          + "&include_globs=" + encodeURIComponent(includeValue)
20685          + "&exclude_globs=" + encodeURIComponent(excludeValue);
20686        fetch(previewUrl)
20687          .then(function (response) { return response.text(); })
20688          .then(function (html) {
20689            if (myGen !== _previewGen) return;
20690            clearInterval(window._previewInterval); window._previewInterval = null;
20691            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20692            setPreviewLoading(false);
20693            previewPanel.innerHTML = html;
20694            attachPreviewInteractions();
20695            syncPythonVisibility();
20696            updateReview();
20697            setTimeout(collapseLanguagePills, 50);
20698            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20699            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20700            var sizeText = document.getElementById('project-size-text');
20701            var sizeBtn = document.getElementById('project-size-btn');
20702            // In server mode with upload sizes available, keep the compressed/original pair.
20703            if (SERVER_MODE && window._lastUploadSizes) {
20704              var us = window._lastUploadSizes;
20705              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20706                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20707              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20708                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20709            } else if (sizeText && projectSize) {
20710              sizeText.textContent = 'Project size: ' + projectSize;
20711              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20712            } else if (sizeText) {
20713              sizeText.textContent = 'Project size: \u2014';
20714            }
20715            if (zeroWarn) {
20716              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20717              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20718              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20719              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20720              if (supportedCount === 0 && fileCount > 0) {
20721                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).';
20722                zeroWarn.style.display = '';
20723              } else {
20724                zeroWarn.style.display = 'none';
20725              }
20726            }
20727          })
20728          .catch(function (err) {
20729            if (myGen !== _previewGen) return;
20730            clearInterval(window._previewInterval); window._previewInterval = null;
20731            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20732            setPreviewLoading(false);
20733            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20734          });
20735      }
20736
20737      function pickDirectory(targetInput, kind) {
20738        if (!targetInput) {
20739          showBannerToast("Directory picker: input element not found.", true);
20740          return;
20741        }
20742        if (SERVER_MODE) {
20743          if (kind === 'output') {
20744            showBannerToast(
20745              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20746              false,
20747              { top: true, icon: '\u{1F4C1}' }
20748            );
20749            return;
20750          }
20751          var inputEl = kind === 'coverage'
20752            ? document.getElementById('cov-upload-input')
20753            : document.getElementById('dir-upload-input');
20754          if (!inputEl) return;
20755          inputEl.onchange = function () {
20756            var files = inputEl.files;
20757            if (!files || files.length === 0) return;
20758            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20759            if (browseBtn) browseBtn.disabled = true;
20760
20761            function fileToBase64(file) {
20762              return new Promise(function (resolve, reject) {
20763                var reader = new FileReader();
20764                reader.onload = function () {
20765                  var b64 = reader.result.split(',')[1];
20766                  resolve(b64);
20767                };
20768                reader.onerror = reject;
20769                reader.readAsDataURL(file);
20770              });
20771            }
20772
20773            if (kind === 'coverage') {
20774              var f = files[0];
20775              if (previewPanel && targetInput === pathInput)
20776                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20777              fileToBase64(f).then(function (b64) {
20778                return fetch('/api/upload-file', {
20779                  method: 'POST',
20780                  headers: { 'Content-Type': 'application/json' },
20781                  body: JSON.stringify({ filename: f.name, content: b64 })
20782                }).then(function (r) { return r.json(); });
20783              })
20784                .then(function (d) {
20785                  if (d && d.tmp_path) {
20786                    if (coverageInput) coverageInput.value = d.tmp_path;
20787                    setCovStatus('idle');
20788                  } else if (d && d.error) { showBannerToast(d.error, true); }
20789                })
20790                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20791                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20792            } else {
20793              // ── Filter to source-code files only ─────────────────────────
20794              // Binary, generated, and dependency files (node_modules, .git,
20795              // build artifacts) are skipped so they are never uploaded.
20796              var CODE_EXTS = new Set([
20797                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20798                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20799                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20800                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20801                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20802                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20803                'tf','hcl','proto','thrift','avsc','graphql','gql'
20804              ]);
20805              var codeFiles = [];
20806              for (var i = 0; i < files.length; i++) {
20807                var f = files[i];
20808                var name = f.name;
20809                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20810                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20811                  codeFiles.push(f); continue;
20812                }
20813                var dot = name.lastIndexOf('.');
20814                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20815              }
20816              // Collect specific .git metadata files for server-side git detection.
20817              // These have no source extension so they are excluded by the loop above,
20818              // but the server needs them to read branch/commit/author without running git.
20819              var gitMetaFiles = [];
20820              for (var i = 0; i < files.length; i++) {
20821                var f = files[i];
20822                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20823                var gitIdx = rp.indexOf('/.git/');
20824                if (gitIdx < 0) continue;
20825                var gitRel = rp.slice(gitIdx + 1);
20826                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20827                    gitRel === '.git/logs/HEAD' ||
20828                    gitRel.startsWith('.git/refs/heads/') ||
20829                    gitRel.startsWith('.git/refs/tags/')) {
20830                  gitMetaFiles.push(f);
20831                }
20832              }
20833              var uploadFiles = codeFiles.concat(gitMetaFiles);
20834              var total = files.length;
20835              var kept = codeFiles.length;
20836              if (kept === 0) {
20837                if (previewPanel && targetInput === pathInput)
20838                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20839                if (browseBtn) browseBtn.disabled = false;
20840                inputEl.value = '';
20841                return;
20842              }
20843
20844              // ── Helper: apply upload result to UI ────────────────────────
20845              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20846              function applyUploadResult(tmpPath, sizes) {
20847                targetInput.value = tmpPath;
20848                scrollInputToEnd(targetInput);
20849                if (sizes && SERVER_MODE) {
20850                  window._lastUploadSizes = sizes;
20851                  // Immediately show both sizes before preview loads.
20852                  var sizeText = document.getElementById('project-size-text');
20853                  var sizeBtn = document.getElementById('project-size-btn');
20854                  if (sizeText) {
20855                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20856                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20857                  }
20858                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20859                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20860                }
20861                if (targetInput === pathInput) {
20862                  updateReportTitleFromPath();
20863                  autoSetOutputDir(tmpPath);
20864                  fetchProjectHistory(tmpPath);
20865                  loadPreview();
20866                  suggestCoverageFile(tmpPath);
20867                }
20868                updateReview();
20869                if (browseBtn) browseBtn.disabled = false;
20870                inputEl.value = '';
20871              }
20872
20873              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20874              if (typeof CompressionStream !== 'undefined') {
20875                if (previewPanel && targetInput === pathInput)
20876                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20877
20878                // Build a minimal POSIX ustar tar header for a single file entry.
20879                function buildUstarHeader(filePath, fileSize) {
20880                  var BLOCK = 512;
20881                  var hdr = new Uint8Array(BLOCK);
20882                  var enc = new TextEncoder();
20883                  function wStr(off, len, s) {
20884                    var b = enc.encode(s);
20885                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20886                  }
20887                  function wOct(off, len, val) {
20888                    var s = val.toString(8);
20889                    while (s.length < len - 1) s = '0' + s;
20890                    wStr(off, len, s + '\0');
20891                  }
20892                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20893                  var name = filePath, prefix = '';
20894                  if (filePath.length > 99) {
20895                    var split = filePath.lastIndexOf('/', 154);
20896                    if (split > 0 && filePath.length - split - 1 <= 99) {
20897                      prefix = filePath.substring(0, split);
20898                      name   = filePath.substring(split + 1);
20899                    } else { name = filePath.substring(0, 99); }
20900                  }
20901                  wStr(0,   100, name);          // name
20902                  wOct(100,   8, 0o000644);      // mode
20903                  wOct(108,   8, 0);             // uid
20904                  wOct(116,   8, 0);             // gid
20905                  wOct(124,  12, fileSize);      // size
20906                  wOct(136,  12, 0);             // mtime (epoch)
20907                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20908                  hdr[156] = 48;                 // type flag '0' = regular file
20909                  wStr(157, 100, '');            // linkname
20910                  wStr(257,   6, 'ustar');       // magic
20911                  wStr(263,   2, '00');          // version
20912                  wStr(265,  32, '');            // uname
20913                  wStr(297,  32, '');            // gname
20914                  wOct(329,   8, 0);             // devmajor
20915                  wOct(337,   8, 0);             // devminor
20916                  wStr(345, 155, prefix);        // prefix
20917                  // Compute checksum (sum of all bytes, placeholder = 32).
20918                  var chk = 0;
20919                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20920                  var cs = chk.toString(8);
20921                  while (cs.length < 6) cs = '0' + cs;
20922                  wStr(148, 8, cs + '\0 ');
20923                  return hdr;
20924                }
20925
20926                // Build tar.gz one file at a time, piping through CompressionStream.
20927                // RAM usage = compressed output buffer + one file at a time.
20928                (async function () {
20929                  try {
20930                    var BLOCK = 512;
20931                    var cs     = new CompressionStream('gzip');
20932                    var writer = cs.writable.getWriter();
20933                    var chunks = [];
20934                    var reader = cs.readable.getReader();
20935                    var collecting = (async function () {
20936                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20937                    })();
20938
20939                    for (var i = 0; i < uploadFiles.length; i++) {
20940                      var file = uploadFiles[i];
20941                      var path = file.webkitRelativePath || file.name;
20942                      var buf  = await file.arrayBuffer();
20943                      var data = new Uint8Array(buf);
20944                      // Header block
20945                      await writer.write(buildUstarHeader(path, data.length));
20946                      // Data padded to 512-byte boundary
20947                      if (data.length > 0) {
20948                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20949                        var block  = new Uint8Array(padded);
20950                        block.set(data);
20951                        await writer.write(block);
20952                      }
20953                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20954                        if (previewPanel && targetInput === pathInput)
20955                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20956                      }
20957                    }
20958                    // End-of-archive: two 512-byte zero blocks
20959                    await writer.write(new Uint8Array(BLOCK * 2));
20960                    await writer.close();
20961                    await collecting;
20962
20963                    var blob = new Blob(chunks, { type: 'application/gzip' });
20964                    var sizeMB = (blob.size / 1048576).toFixed(1);
20965                    if (previewPanel && targetInput === pathInput)
20966                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20967
20968                    var resp = await fetch('/api/upload-tarball', {
20969                      method: 'POST',
20970                      headers: { 'Content-Type': 'application/gzip' },
20971                      body: blob
20972                    });
20973                    var d = await resp.json();
20974                    if (d && d.tmp_path) {
20975                      applyUploadResult(d.tmp_path, {
20976                        compressed_bytes: d.compressed_bytes || 0,
20977                        original_bytes: d.original_bytes || 0
20978                      });
20979                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20980                  } catch (e) {
20981                    showBannerToast('Upload failed: ' + String(e), true);
20982                    if (browseBtn) browseBtn.disabled = false;
20983                    inputEl.value = '';
20984                  }
20985                })();
20986
20987              } else {
20988                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20989                // Used only on browsers that lack CompressionStream (pre-2023).
20990                var BATCH = 200;
20991                var batches = [];
20992                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20993                var totalBatches = batches.length;
20994                if (previewPanel && targetInput === pathInput)
20995                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20996
20997                function sendBatch(idx, currentUploadId, lastTmpPath) {
20998                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20999                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
21000                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21001                  Promise.all(batches[idx].map(function (file) {
21002                    return fileToBase64(file).then(function (b64) {
21003                      return { path: file.webkitRelativePath || file.name, content: b64 };
21004                    });
21005                  })).then(function (fileList) {
21006                    var body = { files: fileList };
21007                    if (currentUploadId) body.upload_id = currentUploadId;
21008                    return fetch('/api/upload-directory', {
21009                      method: 'POST', headers: { 'Content-Type': 'application/json' },
21010                      body: JSON.stringify(body)
21011                    }).then(function (r) { return r.json(); });
21012                  }).then(function (d) {
21013                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21014                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21015                  }).catch(function (e) {
21016                    showBannerToast('Upload failed: ' + String(e), true);
21017                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21018                  });
21019                }
21020                sendBatch(0, null, '');
21021              }
21022            }
21023          };
21024          inputEl.click();
21025          return;
21026        }
21027
21028        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21029        if (browseButton) browseButton.disabled = true;
21030
21031        if (previewPanel && targetInput === pathInput) {
21032          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21033        }
21034
21035        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
21036          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21037          .then(function (data) {
21038            if (data && data.selected_path) {
21039              targetInput.value = data.selected_path;
21040              scrollInputToEnd(targetInput);
21041
21042              if (targetInput === pathInput) {
21043                updateReportTitleFromPath();
21044                autoSetOutputDir(data.selected_path);
21045                fetchProjectHistory(data.selected_path);
21046                loadPreview();
21047                suggestCoverageFile(data.selected_path);
21048              }
21049
21050              updateReview();
21051            } else if (targetInput === pathInput) {
21052              loadPreview();
21053            }
21054          })
21055          .catch(function () {
21056            window.alert("Directory picker request failed.");
21057            if (previewPanel && targetInput === pathInput) {
21058              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21059            }
21060          })
21061          .finally(function () {
21062            if (browseButton) browseButton.disabled = false;
21063          });
21064      }
21065
21066      if (themeToggle) {
21067        themeToggle.addEventListener("click", function () {
21068          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21069          applyTheme(nextTheme);
21070          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21071        });
21072      }
21073
21074      stepButtons.forEach(function (button) {
21075        button.addEventListener("click", function () {
21076          var target = Number(button.getAttribute("data-step-target"));
21077          // Block jumping forward off step 1 while the preview / upload is running
21078          // or while a multi-repository selection is unacknowledged.
21079          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21080          setStep(target);
21081        });
21082      });
21083
21084      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21085        button.addEventListener("click", function () {
21086          var target = Number(button.getAttribute("data-step-target")) || 1;
21087          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21088          setStep(target);
21089        });
21090      });
21091
21092      // True when the project path is untouched from the bundled sample default.
21093      function isDefaultSamplePath() {
21094        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21095      }
21096
21097      var defaultPathOverlay = document.getElementById("default-path-overlay");
21098      function closeDefaultPathModal() {
21099        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21100      }
21101      function openDefaultPathModal() {
21102        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21103      }
21104
21105      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21106        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21107        // that borrow the .next-step style class but carry no data-next target).
21108        if (!button.hasAttribute("data-next")) return;
21109        button.addEventListener("click", function () {
21110          // Guard step 1 → 2: block while the scope preview / upload is still running
21111          // or while a multi-repository selection is unacknowledged.
21112          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21113          // Guard step 1 → 2: warn when the project path is still the sample default.
21114          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21115            openDefaultPathModal();
21116            return;
21117          }
21118          updateReview();
21119          setStep(Number(button.getAttribute("data-next")));
21120        });
21121      });
21122
21123      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21124        if (!button.hasAttribute("data-prev")) return;
21125        button.addEventListener("click", function () {
21126          setStep(Number(button.getAttribute("data-prev")));
21127        });
21128      });
21129
21130      // Default-sample-path confirmation modal wiring.
21131      var defaultPathProceed = document.getElementById("default-path-proceed");
21132      if (defaultPathProceed) {
21133        defaultPathProceed.addEventListener("click", function () {
21134          closeDefaultPathModal();
21135          updateReview();
21136          setStep(2);
21137        });
21138      }
21139      var defaultPathCancel = document.getElementById("default-path-cancel");
21140      if (defaultPathCancel) {
21141        defaultPathCancel.addEventListener("click", function () {
21142          closeDefaultPathModal();
21143          if (pathInput) { pathInput.focus(); pathInput.select(); }
21144        });
21145      }
21146      if (defaultPathOverlay) {
21147        defaultPathOverlay.addEventListener("click", function (e) {
21148          if (e.target === defaultPathOverlay) closeDefaultPathModal();
21149        });
21150      }
21151      document.addEventListener("keydown", function (e) {
21152        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21153          closeDefaultPathModal();
21154        }
21155      });
21156
21157      document.addEventListener("keydown", function (e) {
21158        var tag = (document.activeElement || {}).tagName || "";
21159        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21160        if (e.altKey || e.ctrlKey || e.metaKey) return;
21161        if (e.key === "ArrowRight" && currentStep < 4) {
21162          if (currentStep === 1 && step1ForwardBlocked()) return;
21163          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21164          updateReview(); setStep(currentStep + 1);
21165        }
21166        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21167      });
21168
21169      if (useSamplePath) {
21170        useSamplePath.addEventListener("click", function () {
21171          pathInput.value = "testing/fixtures/basic";
21172          updateReportTitleFromPath();
21173          autoSetOutputDir("testing/fixtures/basic");
21174          loadPreview();
21175          suggestCoverageFile("testing/fixtures/basic");
21176        });
21177      }
21178
21179      if (useDefaultOutput) {
21180        useDefaultOutput.addEventListener("click", function () {
21181          delete outputDirInput.dataset.userEdited;
21182          autoSetOutputDir(pathInput ? pathInput.value : "");
21183          updateReview();
21184        });
21185      }
21186
21187      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21188      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21189
21190      // ── Drag-and-drop directory upload (server mode only) ─────────────────
21191      // Dropping a folder onto the path field bypasses Chrome's
21192      // "Upload X files to this site?" confirmation dialog.
21193      async function readDirRecursively(dirEntry, basePath) {
21194        var reader = dirEntry.createReader();
21195        var all = [];
21196        for (;;) {
21197          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21198          if (!batch.length) break;
21199          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21200        }
21201        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21202        var out = [];
21203        for (var i = 0; i < all.length; i++) {
21204          var sub = all[i];
21205          if (sub.isFile) {
21206            var f = await new Promise(function(res) { sub.file(res); });
21207            out.push({ file: f, path: basePath + '/' + sub.name });
21208          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21209            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21210            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21211          }
21212        }
21213        return out;
21214      }
21215
21216      function setupPathDropZone() {
21217        if (!SERVER_MODE || !pathInput) return;
21218        var CODE_EXTS = new Set([
21219          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21220          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21221          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21222          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21223          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21224          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21225        ]);
21226        pathInput.addEventListener('dragover', function(e) {
21227          e.preventDefault();
21228          pathInput.classList.add('drag-over');
21229        });
21230        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21231        pathInput.addEventListener('drop', function(e) {
21232          e.preventDefault();
21233          pathInput.classList.remove('drag-over');
21234          var items = e.dataTransfer.items;
21235          if (!items || !items.length) return;
21236          var dirEntry = null;
21237          for (var i = 0; i < items.length; i++) {
21238            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21239            if (entry && entry.isDirectory) { dirEntry = entry; break; }
21240          }
21241          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21242          var btn = browsePath;
21243          if (btn) btn.disabled = true;
21244          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21245
21246          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21247            var total = allEntries.length;
21248            var codeEntries = allEntries.filter(function(e) {
21249              var n = e.file.name;
21250              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21251              var dot = n.lastIndexOf('.');
21252              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21253            });
21254            var kept = codeEntries.length;
21255            if (kept === 0) {
21256              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21257              if (btn) btn.disabled = false; return;
21258            }
21259
21260            function finish(tmpPath, sizes) {
21261              pathInput.value = tmpPath;
21262              scrollInputToEnd(pathInput);
21263              if (sizes) {
21264                window._lastUploadSizes = sizes;
21265                var sizeText = document.getElementById('project-size-text');
21266                var sizeBtn = document.getElementById('project-size-btn');
21267                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21268                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21269                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21270                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21271              }
21272              updateReportTitleFromPath();
21273              autoSetOutputDir(tmpPath);
21274              fetchProjectHistory(tmpPath);
21275              loadPreview();
21276              suggestCoverageFile(tmpPath);
21277              updateReview();
21278              if (btn) btn.disabled = false;
21279            }
21280
21281            if (typeof CompressionStream === 'undefined') {
21282              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21283              if (btn) btn.disabled = false; return;
21284            }
21285
21286            try {
21287              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21288              var BLOCK = 512;
21289              var cs = new CompressionStream('gzip');
21290              var wtr = cs.writable.getWriter();
21291              var chunks = [];
21292              var rdr = cs.readable.getReader();
21293              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21294
21295              function buildHdr(fp, sz) {
21296                var hdr = new Uint8Array(BLOCK);
21297                var enc = new TextEncoder();
21298                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]; }
21299                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21300                var nm = fp, pfx = '';
21301                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); } }
21302                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21303                for (var i = 148; i < 156; i++) hdr[i] = 32;
21304                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);
21305                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21306                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21307                return hdr;
21308              }
21309
21310              for (var i = 0; i < codeEntries.length; i++) {
21311                var ce = codeEntries[i];
21312                var buf = await ce.file.arrayBuffer();
21313                var data = new Uint8Array(buf);
21314                await wtr.write(buildHdr(ce.path, data.length));
21315                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21316                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21317                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21318              }
21319              await wtr.write(new Uint8Array(BLOCK * 2));
21320              await wtr.close();
21321              await collecting;
21322
21323              var blob = new Blob(chunks, { type: 'application/gzip' });
21324              var sizeMB = (blob.size / 1048576).toFixed(1);
21325              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21326              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21327              var d = await resp.json();
21328              if (d && d.tmp_path) {
21329                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21330              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21331            } catch (err) {
21332              showBannerToast('Upload failed: ' + String(err), true);
21333              if (btn) btn.disabled = false;
21334            }
21335          }).catch(function(err) {
21336            showBannerToast('Could not read folder: ' + String(err), true);
21337            if (btn) btn.disabled = false;
21338          });
21339        });
21340      }
21341      setupPathDropZone();
21342      if (browseCoverage) {
21343        browseCoverage.addEventListener("click", function () {
21344          pickDirectory(coverageInput || pathInput, "coverage");
21345        });
21346      }
21347
21348      function setCovStatus(state, opts) {
21349        if (!covScanStatus) return;
21350        opts = opts || {};
21351        covScanStatus.className = "cov-scan-status cov-scan-" + state;
21352        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21353        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>';
21354        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>';
21355        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>';
21356        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>';
21357        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21358        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21359        if (state === "scanning") {
21360          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21361        } else if (state === "found") {
21362          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21363          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21364          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21365          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21366        } else if (state === "hint") {
21367          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21368          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
21369          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>';
21370        } else if (state === "none") {
21371          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21372          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
21373        }
21374        html += '</div></div>';
21375        covScanStatus.innerHTML = html;
21376        if (state === "found") {
21377          var useBtn = covScanStatus.querySelector(".cov-scan-use");
21378          if (useBtn) useBtn.addEventListener("click", function () {
21379            if (coverageInput) coverageInput.value = "";
21380            covAutoFilled = false;
21381            setCovStatus("idle");
21382          });
21383        }
21384      }
21385
21386      function suggestCoverageFile(projectPath) {
21387        if (!coverageInput || !covScanStatus) return;
21388        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21389        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21390        clearTimeout(coverageSuggestTimer);
21391        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21392        setCovStatus("scanning");
21393        coverageSuggestTimer = setTimeout(function () {
21394          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21395            .then(function (r) { return r.json(); })
21396            .then(function (d) {
21397              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21398              if (!d) { setCovStatus("none"); return; }
21399              if (d.found) {
21400                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21401                setCovStatus("found", { found: d.found, tool: d.tool });
21402              } else if (d.tool && d.hint) {
21403                setCovStatus("hint", { tool: d.tool, hint: d.hint });
21404              } else {
21405                setCovStatus("none");
21406              }
21407            })
21408            .catch(function () { setCovStatus("idle"); });
21409        }, 600);
21410      }
21411
21412      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21413
21414      if (coverageInput) coverageInput.addEventListener("input", function () {
21415        covAutoFilled = false;
21416        if (!this.value.trim()) setCovStatus("idle");
21417      });
21418
21419      // ── Language pill overflow: collapse to "+N more" chip ─────────────
21420      function collapseLanguagePills() {
21421        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21422        rows.forEach(function(row) {
21423          // Remove any previous overflow chip
21424          var prev = row.querySelector('.lang-overflow-chip');
21425          if (prev) prev.remove();
21426          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21427          pills.forEach(function(p) { p.style.display = ''; });
21428          if (!pills.length) return;
21429
21430          // Measure after restoring all pills
21431          var containerRight = row.getBoundingClientRect().right;
21432          var hidden = [];
21433          for (var i = pills.length - 1; i >= 1; i--) {
21434            var rect = pills[i].getBoundingClientRect();
21435            if (rect.right > containerRight + 2) {
21436              hidden.unshift(pills[i]);
21437              pills[i].style.display = 'none';
21438            } else {
21439              break;
21440            }
21441          }
21442
21443          if (hidden.length) {
21444            var chip = document.createElement('button');
21445            chip.type = 'button';
21446            chip.className = 'language-pill lang-overflow-chip';
21447            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21448            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21449            row.appendChild(chip);
21450          }
21451        });
21452      }
21453
21454      // Run after preview loads (preview panel populates language pills)
21455      var _origLoadPreviewCb = window.__previewLoaded;
21456      document.addEventListener('previewLoaded', collapseLanguagePills);
21457      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21458      setTimeout(collapseLanguagePills, 400);
21459
21460      // ── Project history & output dir auto-set ──────────────────────────
21461      var wsOutputRoot   = document.getElementById("ws-output-root");
21462      var wsScanCount    = document.getElementById("ws-scan-count");
21463      var wsLastScan     = document.getElementById("ws-last-scan");
21464      var historyBadge   = document.getElementById("path-history-badge");
21465      var historyTimer   = null;
21466
21467      var wsOutputLink = document.getElementById("ws-output-link");
21468      function syncStripOutputRoot() {
21469        var val = outputDirInput ? outputDirInput.value : "";
21470        var display = val || "project/sloc";
21471        if (wsOutputRoot) wsOutputRoot.textContent = display;
21472        if (wsOutputLink) wsOutputLink.dataset.folder = val;
21473      }
21474
21475      function scrollInputToEnd(input) {
21476        if (!input) return;
21477        // Defer so the DOM has the new value before we measure scroll width.
21478        requestAnimationFrame(function () {
21479          input.scrollLeft = input.scrollWidth;
21480          input.selectionStart = input.selectionEnd = input.value.length;
21481        });
21482      }
21483
21484      function autoSetOutputDir(projectPath) {
21485        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21486        if (GIT_MODE && GIT_OUTPUT_DIR) {
21487          outputDirInput.value = GIT_OUTPUT_DIR;
21488          scrollInputToEnd(outputDirInput);
21489          syncStripOutputRoot();
21490          updateReview();
21491          return;
21492        }
21493        if (!projectPath || !projectPath.trim()) return;
21494        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21495        outputDirInput.value = cleaned + "/sloc";
21496        scrollInputToEnd(outputDirInput);
21497        syncStripOutputRoot();
21498        updateReview();
21499      }
21500
21501      var wsBranch = document.getElementById("ws-branch");
21502
21503      function fetchProjectHistory(projectPath) {
21504        if (!projectPath || !projectPath.trim()) {
21505          if (wsScanCount) wsScanCount.textContent = "\u2014";
21506          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
21507          if (wsBranch)    wsBranch.textContent    = "\u2014";
21508          if (historyBadge) historyBadge.style.display = "none";
21509          return;
21510        }
21511        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21512          .then(function (r) { return r.ok ? r.json() : null; })
21513          .then(function (data) {
21514            if (!data) return;
21515            var countStr = data.scan_count > 0
21516              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21517              : "never";
21518            var tsStr = data.last_scan_timestamp
21519              ? data.last_scan_timestamp.replace(" UTC","")
21520              : "\u2014";
21521            if (wsScanCount) wsScanCount.textContent = countStr;
21522            if (wsLastScan)  wsLastScan.textContent  = tsStr;
21523            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
21524            if (data.scan_count > 0) {
21525              if (historyBadge) {
21526                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21527                historyBadge.textContent = data.scan_count + " previous scan" +
21528                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21529                  "Last: " + (data.last_scan_timestamp || "\u2014") +
21530                  " \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.";
21531                historyBadge.className = "path-history-badge found";
21532                historyBadge.style.display = "";
21533              }
21534            } else {
21535              if (historyBadge) historyBadge.style.display = "none";
21536            }
21537          })
21538          .catch(function () {});
21539      }
21540
21541      function onPathChange() {
21542        var val = pathInput ? pathInput.value : "";
21543        // Discard stale upload sizes when the user edits the path manually.
21544        window._lastUploadSizes = null;
21545        updateReportTitleFromPath();
21546        autoSetOutputDir(val);
21547        updateSidebarSummary();
21548        clearTimeout(historyTimer);
21549        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21550        if (previewTimer) clearTimeout(previewTimer);
21551        previewTimer = setTimeout(loadPreview, 280);
21552        suggestCoverageFile(val);
21553      }
21554
21555      if (pathInput) {
21556        pathInput.addEventListener("input", onPathChange);
21557      }
21558
21559      if (outputDirInput) {
21560        outputDirInput.addEventListener("input", function () {
21561          outputDirInput.dataset.userEdited = "1";
21562          syncStripOutputRoot();
21563          updateReview();
21564        });
21565      }
21566
21567      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21568        if (!node) return;
21569        node.addEventListener("input", function () {
21570          updateReview();
21571          if (previewTimer) clearTimeout(previewTimer);
21572          previewTimer = setTimeout(loadPreview, 280);
21573        });
21574      });
21575
21576      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21577        var node = document.getElementById(id);
21578        if (node) node.addEventListener("change", updateReview);
21579      });
21580
21581      if (reportTitleInput) {
21582        reportTitleInput.addEventListener("input", function () {
21583          reportTitleTouched = reportTitleInput.value.trim().length > 0;
21584          updateReportTitleFromPath();
21585          updateReview();
21586        });
21587      }
21588
21589      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21590      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21591      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21592      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21593
21594      if (coverageInput) {
21595        coverageInput.addEventListener("input", function () {
21596          if (coverageInput.value.trim()) setCovStatus("idle");
21597        });
21598      }
21599
21600      if (form && loading && submitButton) {
21601        form.addEventListener("submit", function (e) {
21602          e.preventDefault();
21603          submitButton.disabled = true;
21604          submitButton.textContent = "Scanning...";
21605          startAsyncAnalysis(new FormData(form));
21606        });
21607      }
21608
21609      function openPath(folder) {
21610        if (!folder) return;
21611        fetch('/open-path?path=' + encodeURIComponent(folder))
21612          .then(function (r) { return r.json(); })
21613          .then(function (d) {
21614            if (d && d.server_mode_disabled)
21615              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21616          })
21617          .catch(function () {});
21618      }
21619
21620      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21621        btn.addEventListener('click', function () {
21622          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21623        });
21624      });
21625
21626      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21627      if (wsOutputLink) {
21628        wsOutputLink.addEventListener('click', function () {
21629          openPath(wsOutputLink.dataset.folder || '');
21630        });
21631      }
21632
21633      loadSavedTheme();
21634      updateMixedPolicyUI();
21635      updatePythonDocstringUI();
21636      applyScanPreset();
21637      updatePresetDescriptions();
21638      applyArtifactPreset();
21639      updateReview();
21640      updateScrollProgress(); // initialise bar to 0% (step 1)
21641      window.addEventListener("scroll", updateScrollProgress, { passive: true });
21642      onPathChange();         // seed output dir, history badge, and preview from initial path
21643      updateStepNav(1);
21644
21645      // Restore step from URL hash on initial load (e.g., back-forward cache)
21646      (function() {
21647        var hashMatch = location.hash.match(/^#step([1-4])$/);
21648        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21649      })();
21650
21651      (function randomizeWatermarks() {
21652        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21653        if (!wms.length) return;
21654        var placed = [];
21655        function tooClose(top, left) {
21656          for (var i = 0; i < placed.length; i++) {
21657            var dt = Math.abs(placed[i][0] - top);
21658            var dl = Math.abs(placed[i][1] - left);
21659            if (dt < 16 && dl < 12) return true;
21660          }
21661          return false;
21662        }
21663        function pick(leftBand) {
21664          for (var attempt = 0; attempt < 50; attempt++) {
21665            var top = Math.random() * 88 + 2;
21666            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21667            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21668          }
21669          var top = Math.random() * 88 + 2;
21670          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21671          placed.push([top, left]);
21672          return [top, left];
21673        }
21674        var half = Math.floor(wms.length / 2);
21675        wms.forEach(function (img, i) {
21676          var pos = pick(i < half);
21677          var size = Math.floor(Math.random() * 80 + 110);
21678          var rot = (Math.random() * 360).toFixed(1);
21679          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21680          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;
21681        });
21682      })();
21683
21684      (function spawnCodeParticles() {
21685        var container = document.getElementById('code-particles');
21686        if (!container) return;
21687        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'];
21688        for (var i = 0; i < 38; i++) {
21689          (function(idx) {
21690            var el = document.createElement('span');
21691            el.className = 'code-particle';
21692            el.textContent = snippets[idx % snippets.length];
21693            var left = Math.random() * 94 + 2;
21694            var top = Math.random() * 88 + 6;
21695            var dur = (Math.random() * 10 + 9).toFixed(1);
21696            var delay = (Math.random() * 18).toFixed(1);
21697            var rot = (Math.random() * 26 - 13).toFixed(1);
21698            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21699            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';
21700            container.appendChild(el);
21701          })(i);
21702        }
21703      })();
21704    })();
21705  </script>
21706  <script nonce="{{ csp_nonce }}">
21707    (function () {
21708      var raw = {{ prefill_json|safe }};
21709      if (!raw || typeof raw !== 'object' || !raw.path) return;
21710      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21711      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21712      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21713      setVal('path', raw.path || '');
21714      setVal('include_globs', raw.include_globs || '');
21715      setVal('exclude_globs', raw.exclude_globs || '');
21716      setVal('output_dir', raw.output_dir || '');
21717      setVal('report_title', raw.report_title || '');
21718      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21719      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21720      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21721      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21722      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21723      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21724      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21725      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21726      setChecked('generate_html', raw.generate_html !== false);
21727      setChecked('generate_pdf', !!raw.generate_pdf);
21728      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21729      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21730      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21731      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21732      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21733      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21734      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21735      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21736      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21737      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21738      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21739      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21740      // Trigger dynamic UI updates after pre-fill.
21741      setTimeout(function () {
21742        var pathEl = document.getElementById('path');
21743        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21744        var policyEl = document.getElementById('mixed_line_policy');
21745        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21746      }, 80);
21747    })();
21748  </script>
21749  <script nonce="{{ csp_nonce }}">
21750  (function(){
21751    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'}];
21752    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);});}
21753    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21754    function init(){
21755      var btn=document.getElementById('settings-btn');if(!btn)return;
21756      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21757      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>';
21758      document.body.appendChild(m);
21759      var g=document.getElementById('scheme-grid');
21760      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);});
21761      var cl=document.getElementById('settings-close');
21762      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);});})();
21763      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');});
21764      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21765      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21766    }
21767    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21768  }());
21769  </script>
21770  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21771    <div class="wb-ftip-arrow"></div>
21772    <span id="wb-ftip-text"></span>
21773  </div>
21774  <script nonce="{{ csp_nonce }}">(function(){
21775    var tip=document.getElementById('wb-ftip');
21776    var txt=document.getElementById('wb-ftip-text');
21777    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21778    if(!tip||!txt)return;
21779    function pos(el){
21780      var r=el.getBoundingClientRect();
21781      tip.style.display='block';
21782      var tw=tip.offsetWidth;
21783      var lx=r.left+r.width/2-tw/2;
21784      if(lx<8)lx=8;
21785      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21786      tip.style.left=lx+'px';
21787      tip.style.top=(r.bottom+8)+'px';
21788      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';}
21789    }
21790    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21791      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21792      el.addEventListener('mouseleave',function(){tip.style.display='none';});
21793    });
21794    window.addEventListener('blur',function(){tip.style.display='none';});
21795    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21796  })();
21797  (function(){
21798    function fixArtifactHintSpacing(){
21799      var grid=document.querySelector('.artifact-grid');
21800      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21801    }
21802    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21803  }());
21804  (function(){
21805    var dot=document.getElementById('status-dot');
21806    var pingEl=document.getElementById('server-ping-ms');
21807    var tipEl=document.getElementById('server-tip-ping');
21808    var fm=document.getElementById('footer-mode');
21809    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)';}}
21810    function doPing(){
21811      var t0=performance.now();
21812      fetch('/healthz',{cache:'no-store'})
21813        .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);})
21814        .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)';}});
21815    }
21816    doPing();
21817    setInterval(doPing,5000);
21818    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');}
21819  })();
21820  </script>
21821  <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21822  <footer class="site-footer">
21823    local code analysis - metrics, history and reports
21824    &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>
21825    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21826    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21827    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21828    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
21829  </footer>
21830</body>
21831</html>
21832"##,
21833    ext = "html"
21834)]
21835struct IndexTemplate {
21836    version: &'static str,
21837    prefill_json: String,
21838    csp_nonce: String,
21839    git_repo: String,
21840    git_ref: String,
21841    git_label_json: String,
21842    git_output_dir_json: String,
21843    server_mode: bool,
21844}
21845
21846// ── SplashTemplate ────────────────────────────────────────────────────────────
21847
21848#[derive(Template)]
21849#[template(
21850    source = r##"
21851<!doctype html>
21852<html lang="en">
21853<head>
21854  <meta charset="utf-8">
21855  <meta name="viewport" content="width=device-width, initial-scale=1">
21856  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21857  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21858  <script type="application/ld+json">
21859  {
21860    "@context": "https://schema.org",
21861    "@type": "SoftwareApplication",
21862    "name": "oxide-sloc",
21863    "applicationCategory": "DeveloperApplication",
21864    "operatingSystem": "Windows, Linux",
21865    "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.",
21866    "softwareVersion": "{{ version }}",
21867    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21868    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21869    "url": "https://github.com/oxide-sloc/oxide-sloc",
21870    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21871    "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",
21872    "programmingLanguage": "Rust",
21873    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21874  }
21875  </script>
21876  <style nonce="{{ csp_nonce }}">
21877    :root {
21878      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21879      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21880      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21881      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21882      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21883    }
21884    body.dark-theme {
21885      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21886      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21887    }
21888    *{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;}
21889    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21890    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21891    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21892    .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;}
21893    @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));}}
21894    .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);}
21895    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21896    .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));}
21897    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21898    .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;}
21899    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21900    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21901    @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; } }
21902    .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;}
21903    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21904    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21905    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21906    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21907    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21908    .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;}
21909    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21910    .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);}
21911    .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;}
21912    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21913    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21914    .settings-modal-body{padding:14px 16px 16px;}
21915    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21916    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21917    .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;}
21918    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21919    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21920    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21921    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21922    .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;}
21923    .tz-select:focus{border-color:var(--oxide);}
21924    .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;}
21925    .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;}
21926    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21927    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21928    .hero{text-align:center;margin:0 auto 18px;}
21929    .hero-logo-wrap{display:inline-block;cursor:default;}
21930    .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;}
21931    .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;}
21932    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21933    .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;}
21934    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%);}
21935    .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;
21936      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21937      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21938      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;}
21939    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21940    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21941    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;}
21942    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21943    .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;}
21944    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21945    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21946    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21947    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21948    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21949    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21950    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21951    .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;}
21952    .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;}
21953    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21954    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21955    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21956    .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);}
21957    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21958    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21959    .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);}
21960    .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);}
21961    .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);}
21962    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21963    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21964    .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;}
21965    body.dark-theme .action-card-cta{color:var(--oxide);}
21966    .action-card.view .action-card-cta{color:var(--accent-2);}
21967    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21968    .action-card.compare .action-card-cta{color:#7c3aed;}
21969    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21970    .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);}
21971    .action-card.git-tools .action-card-cta{color:#15803d;}
21972    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21973    .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);}
21974    .action-card.trend .action-card-cta{color:#0e7490;}
21975    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21976    .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);}
21977    .action-card.automation .action-card-cta{color:#b45309;}
21978    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21979    .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);}
21980    .action-card.test-metrics .action-card-cta{color:#be185d;}
21981    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21982    .action-card:hover .action-card-cta{gap:12px;}
21983    .action-card.card-split{flex-direction:row;align-items:stretch;}
21984    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21985    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21986    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21987    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21988    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21989    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21990    .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;}
21991    .ac-badge.active{opacity:1;}
21992    .ac-badge.github{border-color:#555;color:#555;}
21993    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21994    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21995    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21996    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21997    body.dark-theme .ac-right-row{color:var(--muted);}
21998    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21999    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22000    .divider{height:1px;background:var(--line);margin:32px 0;}
22001    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22002    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22003    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22004    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22005      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22006    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22007    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22008    body.dark-theme .info-chip-val{color:var(--oxide);}
22009    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22010    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22011      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22012      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22013    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22014      border:6px solid transparent;border-top-color:var(--text);}
22015    .info-chip:hover .info-chip-tip{display:block;}
22016    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22017    .chip-slide.fading{filter:blur(5px);opacity:0;}
22018    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22019    .site-footer a{color:var(--muted);}
22020    .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;}
22021    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22022    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22023    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22024    .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;}
22025    .lan-badge.local{background:var(--oxide-2);}
22026    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22027    .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);}
22028    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22029    .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;}
22030    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22031    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22032    .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;}
22033    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22034    .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;}
22035    .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);}
22036    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22037    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22038    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22039    .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;}
22040    @media (max-height: 1100px) {
22041      .page{padding-top:10px;}
22042      .hero{margin-bottom:10px;}
22043      .hero-logo{width:54px;height:60px;}
22044      .hero-logo-shadow{width:42px;}
22045      .hero-title{font-size:28px;}
22046      .hero-subtitle{font-size:13px;}
22047      .card-sections{gap:12px;margin-bottom:6px;}
22048      .card-section-grid-2,.card-section-grid-3{gap:10px;}
22049      .action-card{padding:8px 15px 8px;}
22050      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22051      .action-card-icon svg{width:18px;height:18px;}
22052      .action-card-title{font-size:13px;}
22053      .action-card-desc{font-size:11px;margin-bottom:6px;}
22054      .action-card-cta{font-size:11px;}
22055      .ac-right-row{font-size:11px;}
22056      .divider{margin:14px 0;}
22057      .info-strip{gap:7px;margin-bottom:8px;}
22058      .info-chip{padding:7px 10px;}
22059      .info-chip-val{font-size:13px;}
22060      .info-chip-label{font-size:9px;}
22061      .site-footer{padding:8px 24px;font-size:12px;}
22062      .lan-local-hint{margin-top:8px;}
22063    }
22064    @media (max-height: 850px) {
22065      .page{padding-top:6px;}
22066      .hero{margin-bottom:6px;}
22067      .hero-logo{width:42px;height:46px;}
22068      .hero-title{font-size:22px;}
22069      .hero-subtitle{font-size:12px;}
22070      .card-sections{gap:10px;}
22071      .action-card-desc{margin-bottom:4px;}
22072      .divider{margin:8px 0;}
22073      .info-strip{margin-bottom:6px;}
22074      .lan-local-hint{margin-top:10px;}
22075    }
22076  </style>
22077</head>
22078<body>
22079  <div class="background-watermarks" aria-hidden="true">
22080    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22081    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22082    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22083    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22084    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22085    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22086    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22087  </div>
22088  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22089  <div class="top-nav">
22090    <div class="top-nav-inner">
22091      <a class="brand" href="/">
22092        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22093        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22094      </a>
22095      <div class="nav-right">
22096        <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22097        <div class="nav-dropdown">
22098          <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>
22099          <div class="nav-dropdown-menu">
22100            <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>
22101          </div>
22102        </div>
22103        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22104        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22105        <div class="nav-dropdown">
22106          <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>
22107          <div class="nav-dropdown-menu">
22108            <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>
22109          </div>
22110        </div>
22111        <div class="server-status-wrap" id="server-status-wrap">
22112          <div class="nav-pill server-online-pill" id="server-status-pill">
22113            <span class="status-dot" id="status-dot"></span>
22114            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22115            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22116          </div>
22117          <div class="server-status-tip">
22118            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22119            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22120          </div>
22121        </div>
22122        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22123          <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>
22124        </button>
22125        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22126          <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>
22127          <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>
22128        </button>
22129      </div>
22130    </div>
22131  </div>
22132
22133  <div class="page">
22134    <div class="hero">
22135      <div class="hero-logo-wrap" id="hero-logo-wrap">
22136        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22137      </div>
22138      <div class="hero-logo-shadow"></div>
22139      <div class="hero-title-wrap">
22140        <div class="hero-title-aura" aria-hidden="true"></div>
22141        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22142      </div>
22143      <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>
22144    </div>
22145
22146    <div class="card-sections">
22147
22148      <div>
22149        <div class="card-section-label">Analysis</div>
22150        <div class="card-section-grid-2">
22151          <a class="action-card scan card-split" href="/scan-setup">
22152            <div class="action-card-left">
22153              <div class="action-card-icon">
22154                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22155              </div>
22156              <div class="action-card-title">Scan Project</div>
22157              <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>
22158              <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>
22159            </div>
22160            <div class="action-card-sep"></div>
22161            <div class="action-card-right">
22162              <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>
22163              <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>
22164              <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>
22165              <div class="ac-right-stat" id="acp-scan-stat"></div>
22166            </div>
22167          </a>
22168          <a class="action-card test-metrics card-split" href="/test-metrics">
22169            <div class="action-card-left">
22170              <div class="action-card-icon">
22171                <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>
22172              </div>
22173              <div class="action-card-title">Test Metrics</div>
22174              <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>
22175              <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>
22176            </div>
22177            <div class="action-card-sep"></div>
22178            <div class="action-card-right">
22179              <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>
22180              <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>
22181              <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>
22182              <div class="ac-right-stat" id="acp-test-stat"></div>
22183            </div>
22184          </a>
22185        </div>
22186      </div>
22187
22188      <div>
22189        <div class="card-section-label">Reports &amp; Insights</div>
22190        <div class="card-section-grid-3">
22191          <a class="action-card view" href="/view-reports">
22192            <div class="action-card-icon">
22193              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22194            </div>
22195            <div class="action-card-title">View Reports</div>
22196            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22197            <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>
22198          </a>
22199          <a class="action-card compare" href="/compare-scans">
22200            <div class="action-card-icon">
22201              <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>
22202            </div>
22203            <div class="action-card-title">Compare Scans</div>
22204            <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>
22205            <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>
22206          </a>
22207          <a class="action-card trend" href="/trend-reports">
22208            <div class="action-card-icon">
22209              <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>
22210            </div>
22211            <div class="action-card-title">Trend Report</div>
22212            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22213            <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>
22214          </a>
22215        </div>
22216      </div>
22217
22218      <div>
22219        <div class="card-section-label">Developer Tools</div>
22220        <div class="card-section-grid-2">
22221          <a class="action-card git-tools card-split" href="/git-browser">
22222            <div class="action-card-left">
22223              <div class="action-card-icon">
22224                <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>
22225              </div>
22226              <div class="action-card-title">Git Browser</div>
22227              <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>
22228              <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>
22229            </div>
22230            <div class="action-card-sep"></div>
22231            <div class="action-card-right">
22232              <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>
22233              <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>
22234              <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>
22235            </div>
22236          </a>
22237          <a class="action-card automation card-split" href="/integrations">
22238            <div class="action-card-left">
22239              <div class="action-card-icon">
22240                <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>
22241              </div>
22242              <div class="action-card-title">Integrations</div>
22243              <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>
22244              <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>
22245            </div>
22246            <div class="action-card-sep"></div>
22247            <div class="action-card-right">
22248              <div class="ac-badges-grid">
22249                <span class="ac-badge github"     id="acp-gh">GitHub</span>
22250                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
22251                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
22252                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22253              </div>
22254              <div class="ac-right-stat" id="acp-int-stat"></div>
22255            </div>
22256          </a>
22257        </div>
22258      </div>
22259
22260    </div>
22261
22262    {% if server_mode %}
22263    <div class="lan-card server">
22264      <div class="lan-card-header">
22265        <span class="lan-badge">LAN server</span>
22266        Accessible on your network
22267      </div>
22268      {% if let Some(ip) = lan_ip %}
22269      <div class="lan-url-row">
22270        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22271        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22272          <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>
22273          Copy URL
22274        </button>
22275      </div>
22276      <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>
22277      {% if has_api_key %}
22278      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
22279      {% endif %}
22280      {% else %}
22281      <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>
22282      {% endif %}
22283    </div>
22284    {% endif %}
22285
22286    <div class="divider"></div>
22287
22288    <div class="info-strip">
22289      <div class="info-chip">
22290        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22291        <div class="chip-slide">
22292          <div class="info-chip-val">60</div>
22293          <div class="info-chip-label">Languages</div>
22294        </div>
22295      </div>
22296      <div class="info-chip">
22297        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22298        <div class="chip-slide">
22299          <div class="info-chip-val">100%</div>
22300          <div class="info-chip-label">Self-contained</div>
22301        </div>
22302      </div>
22303      <div class="info-chip">
22304        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22305        <div class="chip-slide">
22306          <div class="info-chip-val">HTML+PDF</div>
22307          <div class="info-chip-label">Exportable reports</div>
22308        </div>
22309      </div>
22310      <div class="info-chip">
22311        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22312        <div class="chip-slide">
22313          <div class="info-chip-val">Webhook</div>
22314          <div class="info-chip-label">3 platforms</div>
22315        </div>
22316      </div>
22317      <div class="info-chip">
22318        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22319        <div class="chip-slide">
22320          <div class="info-chip-val">IEEE</div>
22321          <div class="info-chip-label">1045-1992</div>
22322        </div>
22323      </div>
22324    </div>
22325
22326    {% if lan_ip.is_none() %}
22327    <div class="lan-local-hint">
22328      <strong>Want teammates on the same network to access this?</strong><br>
22329      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
22330    </div>
22331    {% endif %}
22332  </div>
22333
22334  <footer class="site-footer">
22335    local code analysis - metrics, history and reports
22336    &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>
22337    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22338    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22339    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22340    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22341  </footer>
22342
22343  <script nonce="{{ csp_nonce }}">
22344    (function () {
22345      var storageKey = 'oxide-sloc-theme';
22346      var body = document.body;
22347      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22348      var toggle = document.getElementById('theme-toggle');
22349      if (toggle) toggle.addEventListener('click', function () {
22350        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22351        body.classList.toggle('dark-theme', next === 'dark');
22352        try { localStorage.setItem(storageKey, next); } catch(e) {}
22353      });
22354      var copyBtn = document.getElementById('lan-copy-btn');
22355      if (copyBtn) copyBtn.addEventListener('click', function() {
22356        var btn = this;
22357        var el = document.getElementById('lan-url-val');
22358        if (!el) return;
22359        var url = el.textContent.trim();
22360        if (navigator.clipboard) {
22361          navigator.clipboard.writeText(url).then(function() {
22362            var orig = btn.innerHTML;
22363            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!';
22364            setTimeout(function() { btn.innerHTML = orig; }, 1800);
22365          });
22366        }
22367      });
22368      (function randomizeWatermarks() {
22369        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22370        if (!wms.length) return;
22371        var placed = [];
22372        function tooClose(top, left) {
22373          for (var i = 0; i < placed.length; i++) {
22374            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22375            if (dt < 16 && dl < 12) return true;
22376          }
22377          return false;
22378        }
22379        function pick(leftBand) {
22380          for (var attempt = 0; attempt < 50; attempt++) {
22381            var top = Math.random() * 88 + 2;
22382            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22383            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22384          }
22385          var top = Math.random() * 88 + 2;
22386          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22387          placed.push([top, left]); return [top, left];
22388        }
22389        var half = Math.floor(wms.length / 2);
22390        wms.forEach(function (img, i) {
22391          var pos = pick(i < half);
22392          var size = Math.floor(Math.random() * 100 + 120);
22393          var rot = (Math.random() * 360).toFixed(1);
22394          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22395          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;
22396        });
22397      })();
22398
22399      (function spawnCodeParticles() {
22400        var container = document.getElementById('code-particles');
22401        if (!container) return;
22402        var snippets = [
22403          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22404          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22405          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22406          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22407          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22408        ];
22409        var count = 38;
22410        for (var i = 0; i < count; i++) {
22411          (function(idx) {
22412            var el = document.createElement('span');
22413            el.className = 'code-particle';
22414            var text = snippets[idx % snippets.length];
22415            el.textContent = text;
22416            var left = Math.random() * 94 + 2;
22417            var top = Math.random() * 88 + 6;
22418            var dur = (Math.random() * 10 + 9).toFixed(1);
22419            var delay = (Math.random() * 18).toFixed(1);
22420            var rot = (Math.random() * 26 - 13).toFixed(1);
22421            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22422            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22423              + '--rot:' + rot + 'deg;--op:' + op + ';'
22424              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22425            container.appendChild(el);
22426          })(i);
22427        }
22428      })();
22429      (function heroAnimations() {
22430        var sub = document.getElementById('hero-subtitle');
22431        if (sub) {
22432          var full = sub.textContent.trim();
22433          sub.textContent = '';
22434          sub.style.opacity = '1';
22435          var cursor = document.createElement('span');
22436          cursor.className = 'hero-cursor';
22437          sub.appendChild(cursor);
22438          var i = 0;
22439          setTimeout(function() {
22440            var iv = setInterval(function() {
22441              if (i < full.length) {
22442                sub.insertBefore(document.createTextNode(full[i]), cursor);
22443                i++;
22444              } else {
22445                clearInterval(iv);
22446                setTimeout(function() {
22447                  cursor.style.transition = 'opacity 1s ease';
22448                  cursor.style.opacity = '0';
22449                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22450                }, 2400);
22451              }
22452            }, 11);
22453          }, 374);
22454        }
22455      })();
22456      (function logoBob() {
22457        var logo = document.querySelector('.hero-logo');
22458        var shadow = document.querySelector('.hero-logo-shadow');
22459        if (!logo) return;
22460        var cycleStart = null, cycleDur = 3600;
22461        var peakY = -14, peakScale = 1.07, peakRot = 0;
22462        function newCycle() {
22463          cycleDur = 3000 + Math.random() * 1840;
22464          peakY = -(9 + Math.random() * 13.8);
22465          peakScale = 1.04 + Math.random() * 0.081;
22466          peakRot = (Math.random() * 11.5 - 5.75);
22467        }
22468        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22469        newCycle();
22470        function frame(ts) {
22471          if (cycleStart === null) cycleStart = ts;
22472          var t = (ts - cycleStart) / cycleDur;
22473          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22474          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22475          var y = peakY * phase;
22476          var sc = 1 + (peakScale - 1) * phase;
22477          var rot = peakRot * Math.sin(Math.PI * phase);
22478          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22479          if (shadow) {
22480            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22481            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22482          }
22483          requestAnimationFrame(frame);
22484        }
22485        requestAnimationFrame(frame);
22486      })();
22487      (function mouseEffects() {
22488        var heroTitle = document.getElementById('hero-title');
22489        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22490        function tick() {
22491          raf = null;
22492          if (heroTitle) {
22493            var r = heroTitle.getBoundingClientRect();
22494            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22495            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22496            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22497          }
22498        }
22499        document.addEventListener('mousemove', function(e) {
22500          mx = e.clientX; my = e.clientY;
22501          if (!raf) raf = requestAnimationFrame(tick);
22502        });
22503        document.addEventListener('mouseleave', function() {
22504          if (heroTitle) {
22505            heroTitle.style.transition = 'transform 0.5s ease';
22506            heroTitle.style.transform = '';
22507            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22508          }
22509        });
22510        document.querySelectorAll('.action-card').forEach(function(card) {
22511          card.addEventListener('mousemove', function(e) {
22512            var rect = card.getBoundingClientRect();
22513            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22514            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22515            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22516            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22517          });
22518          card.addEventListener('mouseleave', function() {
22519            card.style.transition = '';
22520            card.style.transform = '';
22521          });
22522        });
22523      })();
22524      (function chipSlideshow() {
22525        var slides = [
22526          [{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'}],
22527          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22528          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22529          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22530          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22531        ];
22532        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22533        var indices = [0,0,0,0,0];
22534        var paused = [false,false,false,false,false];
22535        chips.forEach(function(chip, i) {
22536          chip.addEventListener('mouseenter', function() { paused[i] = true; });
22537          chip.addEventListener('mouseleave', function() { paused[i] = false; });
22538        });
22539        function advance(i) {
22540          if (paused[i]) return;
22541          var chip = chips[i];
22542          var inner = chip.querySelector('.chip-slide');
22543          if (!inner) return;
22544          inner.classList.add('fading');
22545          setTimeout(function() {
22546            indices[i] = (indices[i] + 1) % slides[i].length;
22547            var s = slides[i][indices[i]];
22548            chip.querySelector('.info-chip-val').textContent = s.v;
22549            chip.querySelector('.info-chip-label').textContent = s.l;
22550            inner.classList.remove('fading');
22551          }, 720);
22552        }
22553        setInterval(function() {
22554          chips.forEach(function(chip, i) { advance(i); });
22555        }, 6000);
22556      })();
22557      (function cardLiveData() {
22558        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22559          var el = document.getElementById('acp-scan-stat');
22560          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22561        }).catch(function(){});
22562        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22563          var el = document.getElementById('acp-test-stat');
22564          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22565        }).catch(function(){});
22566        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22567          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22568          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22569          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22570          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22571          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22572          var stat = document.getElementById('acp-int-stat');
22573          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22574        }).catch(function(){});
22575        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22576          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22577        }).catch(function(){});
22578      })();
22579    })();
22580  </script>
22581  <script nonce="{{ csp_nonce }}">
22582  (function(){
22583    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'}];
22584    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);});}
22585    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22586    function init(){
22587      var btn=document.getElementById('settings-btn');if(!btn)return;
22588      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22589      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>';
22590      document.body.appendChild(m);
22591      var g=document.getElementById('scheme-grid');
22592      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);});
22593      var cl=document.getElementById('settings-close');
22594      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);});})();
22595      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');});
22596      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22597      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22598    }
22599    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22600  }());
22601  </script>
22602  <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>
22603</body>
22604</html>
22605"##,
22606    ext = "html"
22607)]
22608struct SplashTemplate {
22609    csp_nonce: String,
22610    server_mode: bool,
22611    lan_ip: Option<String>,
22612    port: u16,
22613    version: &'static str,
22614    has_api_key: bool,
22615}
22616
22617// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
22618
22619#[derive(Template)]
22620#[template(
22621    source = r##"
22622<!doctype html>
22623<html lang="en">
22624<head>
22625  <meta charset="utf-8">
22626  <meta name="viewport" content="width=device-width, initial-scale=1">
22627  <title>OxideSLOC — Start a Scan</title>
22628  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22629  <style nonce="{{ csp_nonce }}">
22630    :root {
22631      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22632      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22633      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22634      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22635      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22636    }
22637    body.dark-theme {
22638      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22639      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22640    }
22641    *{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;}
22642    .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);}
22643    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22644    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22645    .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));}
22646    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22647    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22648    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22649    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22650    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22651    @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; } }
22652    .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;}
22653    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22654    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22655    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22656    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22657    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22658    .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;}
22659    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22660    .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);}
22661    .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;}
22662    .settings-close:hover{color:var(--text);background:var(--surface-2);}
22663    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22664    .settings-modal-body{padding:14px 16px 16px;}
22665    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22666    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22667    .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;}
22668    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22669    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22670    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22671    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22672    .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;}
22673    .tz-select:focus{border-color:var(--oxide);}
22674    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22675    .page-header{text-align:center;margin-bottom:16px;}
22676    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22677    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22678    /* Cards */
22679    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22680    .option-card-wrap{position:relative;}
22681    .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;}
22682    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22683    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22684    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22685    .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;}
22686    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22687    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22688    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22689    .card-top-row{display:flex;align-items:center;gap:20px;}
22690    /* Two-column layout inside each card */
22691    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22692    .card-left{display:flex;align-items:flex-start;min-width:0;}
22693    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22694    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22695    .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);}
22696    .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);}
22697    .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);}
22698    .card-text{min-width:0;}
22699    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22700    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22701    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22702    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22703    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22704    /* Right CTA column */
22705    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22706    .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;}
22707    /* Re-scan count badge */
22708    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22709    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22710    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22711    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22712    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22713    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22714    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22715    body.dark-theme .btn-secondary{color:var(--oxide);}
22716    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22717    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22718    /* File input overlay — must be full-width so it aligns with other card-right buttons */
22719    .file-input-wrap{position:relative;width:100%;}
22720    .file-input-wrap .btn{width:100%;}
22721    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22722    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22723    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22724    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22725    .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;}
22726    @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));}}
22727    /* Recent list (card 3 — full-width section below header) */
22728    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22729    .recent-list{display:flex;flex-direction:column;gap:8px;}
22730    .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;}
22731    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22732    .recent-item-info{flex:1;min-width:0;}
22733    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22734    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22735    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22736    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22737    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22738    .site-footer a{color:var(--muted);}
22739    @media(max-width:680px){
22740      .card-body{grid-template-columns:1fr;}
22741      .card-right{flex-direction:row;flex-wrap:wrap;}
22742      .btn{flex:1;}
22743    }
22744    .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;}
22745    .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;}
22746    .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;}
22747  </style>
22748</head>
22749<body>
22750  <div class="background-watermarks" aria-hidden="true">
22751    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22752    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22753    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22754    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22755    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22756    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22757    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22758  </div>
22759  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22760  <div class="top-nav">
22761    <div class="top-nav-inner">
22762      <a class="brand" href="/">
22763        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22764        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22765      </a>
22766      <div class="nav-right">
22767        <a class="nav-pill" href="/">Home</a>
22768        <div class="nav-dropdown">
22769          <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>
22770          <div class="nav-dropdown-menu">
22771            <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>
22772          </div>
22773        </div>
22774        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22775        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22776        <div class="nav-dropdown">
22777          <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>
22778          <div class="nav-dropdown-menu">
22779            <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>
22780          </div>
22781        </div>
22782        <div class="server-status-wrap" id="server-status-wrap">
22783          <div class="nav-pill server-online-pill" id="server-status-pill">
22784            <span class="status-dot" id="status-dot"></span>
22785            <span id="server-status-label">Server</span>
22786            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22787          </div>
22788          <div class="server-status-tip">
22789            OxideSLOC is running — accessible on your network.
22790            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22791          </div>
22792        </div>
22793        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22794          <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>
22795        </button>
22796        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22797          <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>
22798          <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>
22799        </button>
22800      </div>
22801    </div>
22802  </div>
22803
22804  <div class="page">
22805    <div class="page-header">
22806      <h1>How would you like to scan?</h1>
22807      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22808    </div>
22809
22810    <div class="option-grid">
22811
22812      <!-- Option 1: New scan -->
22813      <div class="option-card-wrap">
22814        <div class="option-card">
22815        <div class="option-icon new-scan">
22816          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22817        </div>
22818        <div class="card-body">
22819          <div class="card-left">
22820            <div class="card-text">
22821              <div class="option-title">Start a new scan</div>
22822              <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>
22823              <ul class="feature-list">
22824                <li>Live project scope preview before you run</li>
22825                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22826                <li>HTML, PDF, and JSON output — your choice</li>
22827              </ul>
22828            </div>
22829          </div>
22830          <div class="card-right">
22831            <a class="btn btn-primary" href="/scan">
22832              Configure &amp; scan
22833              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22834            </a>
22835            <p class="card-tip">Full 4-step setup · all options</p>
22836          </div>
22837        </div>
22838        </div>
22839      </div>
22840
22841      <!-- Option 2: Load from config file -->
22842      <div class="option-card-wrap">
22843        <div class="option-card">
22844        <div class="option-icon load-config">
22845          <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>
22846        </div>
22847        <div class="card-body">
22848          <div class="card-left">
22849            <div class="card-text">
22850              <div class="option-title">Load a saved config</div>
22851              <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>
22852              <ul class="feature-list">
22853                <li>All 15 settings restored from the file</li>
22854                <li>Fully editable — change path or output dir</li>
22855                <li>Works with any scan-config.json</li>
22856              </ul>
22857            </div>
22858          </div>
22859          <div class="card-right">
22860            <div class="file-input-wrap">
22861              <button class="btn btn-secondary" id="load-config-btn" type="button">
22862                <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>
22863                Choose config file
22864              </button>
22865              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22866            </div>
22867            <p class="card-tip" id="config-file-name">Exported after every scan</p>
22868          </div>
22869        </div>
22870        </div>
22871      </div>
22872
22873      <!-- Option 3: Re-scan recent project -->
22874      <div class="option-card-wrap">
22875        <div class="option-card" id="recent-card">
22876        <div class="card-top-row">
22877          <div class="option-icon rescan">
22878            <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>
22879          </div>
22880          <div class="card-body">
22881            <div class="card-left">
22882              <div class="card-text">
22883                <div class="option-title">Re-scan a recent project</div>
22884                <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>
22885                <ul class="feature-list">
22886                  <li>All 15+ settings restored from the saved config</li>
22887                  <li>Path and output dir are editable before running</li>
22888                  <li>Only scans with a saved config appear here</li>
22889                </ul>
22890              </div>
22891            </div>
22892            <div class="card-right">
22893              <div class="rescan-count-box">
22894                <div class="rescan-count-num" id="rescan-count-num">—</div>
22895                <div class="rescan-count-label">saved configs</div>
22896              </div>
22897              <a class="btn btn-secondary" href="/view-reports">
22898                <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>
22899                View all runs
22900              </a>
22901              <p class="card-tip">Opens run history</p>
22902            </div>
22903          </div>
22904        </div>
22905        <div class="section-divider"></div>
22906        <div class="recent-list" id="recent-list">
22907          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22908        </div>
22909        </div>
22910      </div>
22911
22912    </div>
22913  </div>
22914
22915  <footer class="site-footer">
22916    local code analysis - metrics, history and reports
22917    &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>
22918    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22919    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22920    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22921    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
22922  </footer>
22923
22924  <script nonce="{{ csp_nonce }}">
22925    (function () {
22926      var storageKey = 'oxide-sloc-theme';
22927      var body = document.body;
22928      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22929      var toggle = document.getElementById('theme-toggle');
22930      if (toggle) toggle.addEventListener('click', function () {
22931        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22932        body.classList.toggle('dark-theme', next === 'dark');
22933        try { localStorage.setItem(storageKey, next); } catch(e) {}
22934      });
22935
22936      (function randomizeWatermarks() {
22937        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22938        if (!wms.length) return;
22939        var placed = [];
22940        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; }
22941        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]; }
22942        var half = Math.floor(wms.length / 2);
22943        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; });
22944      })();
22945      (function spawnCodeParticles() {
22946        var container = document.getElementById('code-particles');
22947        if (!container) return;
22948        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'];
22949        var count = 38;
22950        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); }
22951      })();
22952      // Recent scans data injected from server
22953      var recentScans = {{ recent_scans_json|safe }};
22954
22955      function configToParams(cfg) {
22956        var p = new URLSearchParams();
22957        p.set('prefilled', '1');
22958        if (cfg.path) p.set('path', cfg.path);
22959        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22960        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22961        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22962        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22963        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22964        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22965        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22966        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22967        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22968        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22969        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22970        if (cfg.report_title) p.set('report_title', cfg.report_title);
22971        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22972        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22973        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22974        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22975        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22976        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22977        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22978        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22979        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22980        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22981        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22982        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22983        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22984        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22985        return p;
22986      }
22987
22988      // Build recent scan list (capped at 3 visible entries)
22989      var list = document.getElementById('recent-list');
22990      var noNote = document.getElementById('no-recent-note');
22991      var hasAny = false;
22992      var MAX_RECENT = 3;
22993      if (Array.isArray(recentScans)) {
22994        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22995        var shown = 0;
22996        validEntries.forEach(function (entry) {
22997          if (shown >= MAX_RECENT) return;
22998          shown++;
22999          hasAny = true;
23000          var item = document.createElement('div');
23001          item.className = 'recent-item';
23002          item.title = 'Restore all settings and open wizard';
23003          item.innerHTML =
23004            '<div class="recent-item-info">' +
23005              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23006              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
23007            '</div>' +
23008            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23009          item.addEventListener('click', function () {
23010            var params = configToParams(entry.config);
23011            window.location.href = '/scan?' + params.toString();
23012          });
23013          list.appendChild(item);
23014        });
23015        if (validEntries.length > MAX_RECENT) {
23016          var moreEl = document.createElement('div');
23017          moreEl.className = 'recent-more-link';
23018          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
23019          list.appendChild(moreEl);
23020        }
23021      }
23022      if (hasAny && noNote) noNote.style.display = 'none';
23023      // Update count badge
23024      var countEl = document.getElementById('rescan-count-num');
23025      if (countEl) {
23026        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23027        countEl.textContent = total > 0 ? total : '0';
23028      }
23029
23030      // Config file loader
23031      var fileInput = document.getElementById('config-file-input');
23032      var fileName = document.getElementById('config-file-name');
23033      var loadBtn = document.getElementById('load-config-btn');
23034      // Wire the visible button to open the hidden file picker.
23035      if (loadBtn && fileInput) {
23036        loadBtn.addEventListener('click', function () { fileInput.click(); });
23037      }
23038      if (fileInput) {
23039        fileInput.addEventListener('change', function () {
23040          var file = fileInput.files && fileInput.files[0];
23041          if (!file) return;
23042          if (fileName) fileName.textContent = '\u2713 ' + file.name;
23043          var reader = new FileReader();
23044          reader.onload = function (e) {
23045            try {
23046              var cfg = JSON.parse(e.target.result);
23047              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23048              var params = configToParams(cfg);
23049              window.location.href = '/scan?' + params.toString();
23050            } catch (err) {
23051              alert('Could not parse config file: ' + err.message);
23052            }
23053          };
23054          reader.readAsText(file);
23055        });
23056      }
23057
23058      function escHtml(s) {
23059        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
23060      }
23061    })();
23062  </script>
23063  <script nonce="{{ csp_nonce }}">
23064  (function(){
23065    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'}];
23066    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);});}
23067    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23068    function init(){
23069      var btn=document.getElementById('settings-btn');if(!btn)return;
23070      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23071      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>';
23072      document.body.appendChild(m);
23073      var g=document.getElementById('scheme-grid');
23074      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);});
23075      var cl=document.getElementById('settings-close');
23076      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);});})();
23077      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');});
23078      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23079      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23080    }
23081    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23082  }());
23083  </script>
23084  <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]';
23085  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;}
23086  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>
23087</body>
23088</html>
23089"##,
23090    ext = "html"
23091)]
23092struct ScanSetupTemplate {
23093    version: &'static str,
23094    recent_scans_json: String,
23095    csp_nonce: String,
23096}
23097
23098#[derive(Template)]
23099#[template(
23100    source = r##"
23101<!doctype html>
23102<html lang="en">
23103<head>
23104  <meta charset="utf-8">
23105  <meta name="viewport" content="width=device-width, initial-scale=1">
23106  <title>OxideSLOC | {{ report_title }} | Report</title>
23107  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23108  <style nonce="{{ csp_nonce }}">
23109    :root {
23110      --radius: 18px;
23111      --bg: #f5efe8;
23112      --surface: rgba(255,255,255,0.82);
23113      --surface-2: #fbf7f2;
23114      --surface-3: #efe6dc;
23115      --line: #e6d0bf;
23116      --line-strong: #dcb89f;
23117      --text: #43342d;
23118      --muted: #7b675b;
23119      --muted-2: #a08777;
23120      --nav: #b85d33;
23121      --nav-2: #7a371b;
23122      --accent: #6f9bff;
23123      --accent-2: #4a78ee;
23124      --oxide: #d37a4c;
23125      --oxide-2: #b35428;
23126      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23127      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23128      --success-bg: #e8f5ed;
23129      --success-text: #1a8f47;
23130      --info-bg: #eef3ff;
23131      --info-text: #4467d8;
23132    }
23133
23134    body.dark-theme {
23135      --bg: #1b1511;
23136      --surface: #261c17;
23137      --surface-2: #2d221d;
23138      --surface-3: #372922;
23139      --line: #524238;
23140      --line-strong: #6c5649;
23141      --text: #f5ece6;
23142      --muted: #c7b7aa;
23143      --muted-2: #aa9485;
23144      --nav: #b85d33;
23145      --nav-2: #7a371b;
23146      --accent: #6f9bff;
23147      --accent-2: #4a78ee;
23148      --oxide: #d37a4c;
23149      --oxide-2: #b35428;
23150      --shadow: 0 18px 42px rgba(0,0,0,0.28);
23151      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23152      --success-bg: #163927;
23153      --success-text: #8fe2a8;
23154      --info-bg: #1c2847;
23155      --info-text: #a9c1ff;
23156    }
23157
23158    * { box-sizing: border-box; }
23159    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); }
23160    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23161    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23162    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23163    .top-nav, .page { position: relative; z-index: 2; }
23164    .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); }
23165    .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; }
23166    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23167    .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)); }
23168    .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; }
23169    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23170    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23171    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23172    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23173    .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; }
23174    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23175    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23176    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23177    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23178    @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; } }
23179    .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; }
23180    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23181    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23182    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23183    .theme-toggle .icon-sun { display:none; }
23184    body.dark-theme .theme-toggle .icon-sun { display:block; }
23185    body.dark-theme .theme-toggle .icon-moon { display:none; }
23186    .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;}
23187    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23188    .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);}
23189    .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;}
23190    .settings-close:hover{color:var(--text);background:var(--surface-2);}
23191    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23192    .settings-modal-body{padding:14px 16px 16px;}
23193    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23194    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23195    .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;}
23196    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23197    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23198    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23199    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23200    .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;}
23201    .tz-select:focus{border-color:var(--oxide);}
23202    .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; }
23203    .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;}
23204    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23205    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23206    .hero, .panel { padding: 22px; }
23207    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23208    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23209    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23210    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23211    .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; }
23212    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23213    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23214    .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; }
23215    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23216    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23217    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23218    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23219    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23220    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23221    .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); }
23222    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23223    .delta-card-val { font-size:16px; font-weight:800; }
23224    .delta-card-val.pos { color:#1e7e34; }
23225    .delta-card-val.neg { color:var(--neg); }
23226    .delta-card-val.mod { color:#b35428; }
23227    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23228    .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; }
23229    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23230    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23231    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23232    .compare-ts { font-size:13px; color:var(--muted); }
23233    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23234    .compare-arrow { color: var(--muted); }
23235    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23236    .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; }
23237    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23238    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23239    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23240    .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; }
23241    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23242    .run-mgmt-card .action-buttons { justify-content:center; }
23243    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23244    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23245    .button, .copy-button {
23246      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;
23247    }
23248    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23249    @keyframes spin { to { transform: rotate(360deg); } }
23250    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23251    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23252    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23253    .path-item strong { display: block; margin-bottom: 6px; }
23254    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23255    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23256    .path-subitem { flex: 1; }
23257    .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); }
23258    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); }
23259    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23260    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23261    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23262    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23263    th { color: var(--muted); font-weight: 700; }
23264    tr:last-child td { border-bottom: none; }
23265    #subm-tbl col:nth-child(1){width:15%;}
23266    #subm-tbl col:nth-child(2){width:31%;}
23267    #subm-tbl col:nth-child(3){width:9%;}
23268    #subm-tbl col:nth-child(4){width:9%;}
23269    #subm-tbl col:nth-child(5){width:9%;}
23270    #subm-tbl col:nth-child(6){width:9%;}
23271    #subm-tbl col:nth-child(7){width:9%;}
23272    #subm-tbl col:nth-child(8){width:9%;}
23273    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23274    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23275    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23276    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23277    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23278    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23279    .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; }
23280    .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; }
23281    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23282    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23283    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23284    .muted { color: var(--muted); }
23285    /* Run-ID chip row (mirrors HTML report) */
23286    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23287    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23288    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23289    .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; }
23290    .run-id-chip[data-copy] { cursor:pointer; }
23291    a.run-id-chip { text-decoration:none; cursor:pointer; }
23292    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23293    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23294    .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; }
23295    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23296    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23297    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23298    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23299    a.commit-link-value { color:inherit; text-decoration:none; }
23300    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23301    .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; }
23302    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23303    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23304    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23305    .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; }
23306    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23307    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23308    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23309    /* Meta chips row */
23310    .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%; }
23311    .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; }
23312    .meta-chip:last-child { border-right:none; }
23313    .meta-chip b { color:var(--text); font-weight:700; }
23314    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23315    .site-footer a{color:var(--muted);}
23316    .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; }
23317    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23318    .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; }
23319    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23320    /* Stat chips (matches HTML report) */
23321    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23322    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23323    /* Hero stat strip: uniform grid where every card is the same width and the
23324       columns line up across both rows. JS sets the column count to ceil(n/2) so
23325       the cards always occupy exactly two rows; when the count is odd the last
23326       card spans two columns to fill the trailing cell with no empty gap. */
23327    .summary-strip-hero { align-items:stretch; }
23328    .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; }
23329    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23330    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23331    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23332    .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; }
23333    .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); }
23334    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23335    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23336    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23337    .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; }
23338    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23339    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23340    .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); }
23341    .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); }
23342    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23343    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23344    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23345    /* Submodule panel */
23346    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23347    /* Metrics tables stack */
23348    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23349    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23350    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23351    .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)); }
23352    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23353    /* Metrics table */
23354    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23355    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23356    .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; }
23357    .metrics-table thead th:not(:first-child) { text-align: right; }
23358    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23359    .metrics-table tbody tr:last-child td { border-bottom: none; }
23360    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23361    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23362    .metrics-table tbody tr:hover td { background: var(--surface-2); }
23363    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23364    .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; }
23365    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23366    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23367    .mt-val-pos { color: var(--pos); font-weight: 700; }
23368    .mt-val-neg { color: var(--neg); font-weight: 700; }
23369    .mt-val-zero { color: var(--muted); }
23370    .mt-val-mod { color: var(--oxide-2); }
23371    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23372    @media (max-width: 1180px) {
23373      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23374      .nav-project-slot, .nav-status { justify-content:flex-start; }
23375      .hero-top { flex-direction: column; }
23376      .run-mgmt-strip { flex-direction: column; }
23377    }
23378    .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;}
23379    @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));}}
23380    .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;}
23381    /* ── Result-page chart controls ─────────────────────────────────────────── */
23382    .r-chart-section{margin-bottom:24px;}
23383    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23384    .section-pair > .panel{flex-shrink:0;}
23385    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23386    .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;}
23387    .r-chart-select:focus{border-color:var(--accent);}
23388    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23389    .r-chart-container svg{display:block;width:100%;height:auto;}
23390    .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;}
23391    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23392    .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;}
23393    .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);}
23394    .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;}
23395    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23396    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23397    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23398    .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;}
23399    .r-chart-modal-close:hover{opacity:.7;}
23400    body.dark-theme .r-chart-modal{background:var(--surface);}
23401    .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;}
23402    .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);}
23403    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23404    .lang-bar-row:hover{transform:translateY(-2px);}
23405    .lang-bar-row .rchit:hover{filter:none;transform:none;}
23406    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23407    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23408    .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;}
23409    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23410    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23411    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23412    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23413    #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;}
23414    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23415    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23416    .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;}
23417    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23418    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23419    .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;}
23420    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23421    .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;}
23422    .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;}
23423    body.has-report-banner .top-nav{top:27px;}
23424    body.has-report-banner{padding-bottom:27px;}
23425  </style>
23426</head>
23427<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23428  <div class="background-watermarks" aria-hidden="true">
23429    <img src="/images/logo/logo-text.png" alt="" />
23430    <img src="/images/logo/logo-text.png" alt="" />
23431    <img src="/images/logo/logo-text.png" alt="" />
23432    <img src="/images/logo/logo-text.png" alt="" />
23433    <img src="/images/logo/logo-text.png" alt="" />
23434    <img src="/images/logo/logo-text.png" alt="" />
23435    <img src="/images/logo/logo-text.png" alt="" />
23436    <img src="/images/logo/logo-text.png" alt="" />
23437    <img src="/images/logo/logo-text.png" alt="" />
23438    <img src="/images/logo/logo-text.png" alt="" />
23439    <img src="/images/logo/logo-text.png" alt="" />
23440    <img src="/images/logo/logo-text.png" alt="" />
23441    <img src="/images/logo/logo-text.png" alt="" />
23442    <img src="/images/logo/logo-text.png" alt="" />
23443  </div>
23444  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23445  {% if let Some(banner) = report_header_footer %}
23446  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23447  {% endif %}
23448  <div class="top-nav">
23449    <div class="top-nav-inner">
23450      <a class="brand" href="/">
23451        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23452        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23453      </a>
23454      <div class="nav-project-slot">
23455        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23456      </div>
23457      <div class="nav-status">
23458        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23459        <div class="nav-dropdown">
23460          <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>
23461          <div class="nav-dropdown-menu">
23462            <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>
23463          </div>
23464        </div>
23465        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23466        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23467        <div class="nav-dropdown">
23468          <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>
23469          <div class="nav-dropdown-menu">
23470            <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>
23471          </div>
23472        </div>
23473        <div class="server-status-wrap" id="server-status-wrap">
23474          <div class="nav-pill server-online-pill" id="server-status-pill">
23475            <span class="status-dot" id="status-dot"></span>
23476            <span id="server-status-label">Server</span>
23477            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23478          </div>
23479          <div class="server-status-tip">
23480            OxideSLOC is running — accessible on your network.
23481            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23482          </div>
23483        </div>
23484        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23485          <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>
23486        </button>
23487        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23488          <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>
23489          <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>
23490        </button>
23491      </div>
23492    </div>
23493  </div>
23494
23495  <div class="page">
23496    <section class="hero">
23497      <div class="hero-top">
23498        <div>
23499          <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23500            <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23501            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23502            <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>
23503          </div>
23504        </div>
23505        <div class="hero-quick-actions">
23506          {% if server_mode %}
23507          <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>
23508          {% else %}
23509          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23510          {% endif %}
23511          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23512          {% if !server_mode %}
23513          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23514          {% endif %}
23515          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23516          <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>
23517        </div>
23518      </div>
23519
23520      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23521      <div class="run-id-row">
23522        <span class="run-id-chip" data-copy="{{ run_id }}">
23523          <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>
23524          <span class="run-id-chip-value">{{ run_id }}</span>
23525          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23526        </span>
23527        {% match git_commit_long %}
23528          {% when Some with (long_sha) %}
23529          {% match git_commit_url %}
23530            {% when Some with (commit_url) %}
23531            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23532              <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>
23533              <span class="run-id-chip-value">{{ long_sha }}</span>
23534              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23535            </a>
23536            {% when None %}
23537            <span class="run-id-chip" data-copy="{{ long_sha }}">
23538              <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>
23539              <span class="run-id-chip-value">{{ long_sha }}</span>
23540              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23541            </span>
23542          {% endmatch %}
23543          {% when None %}
23544          <span class="run-id-chip muted-chip">
23545            <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>
23546            <span class="run-id-chip-value">Not detected</span>
23547            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23548          </span>
23549        {% endmatch %}
23550        {% match git_branch %}
23551          {% when Some with (branch) %}
23552          {% match git_branch_url %}
23553            {% when Some with (branch_url) %}
23554            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23555              <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>
23556              <span class="run-id-chip-value">{{ branch }}</span>
23557              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23558            </a>
23559            {% when None %}
23560            <span class="run-id-chip">
23561              <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>
23562              <span class="run-id-chip-value">{{ branch }}</span>
23563              <span class="chip-tooltip">Git branch active at scan time</span>
23564            </span>
23565          {% endmatch %}
23566          {% when None %}
23567          <span class="run-id-chip muted-chip">
23568            <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>
23569            <span class="run-id-chip-value">Not detected</span>
23570            <span class="chip-tooltip">No Git branch was found for this scan</span>
23571          </span>
23572        {% endmatch %}
23573        {% match git_author %}
23574          {% when Some with (author) %}
23575          <span class="run-id-chip" data-author="{{ author }}">
23576            <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>
23577            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23578            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23579          </span>
23580          {% when None %}
23581          <span class="run-id-chip muted-chip">
23582            <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>
23583            <span class="run-id-chip-value">Not detected</span>
23584            <span class="chip-tooltip">No commit author was found for this scan</span>
23585          </span>
23586        {% endmatch %}
23587      </div>
23588
23589      <!-- Scan metadata row -->
23590      <div class="meta">
23591        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23592        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23593        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23594        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23595        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23596      </div>
23597
23598      <!-- All summary stat chips in one unified strip (8 columns) -->
23599      <div class="summary-strip summary-strip-hero">
23600        <div class="stat-chip" data-raw="{{ physical_lines }}">
23601          <div class="stat-chip-label">Physical lines</div>
23602          <div class="stat-chip-val">{{ physical_lines }}</div>
23603          <div class="stat-chip-exact"></div>
23604          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23605        </div>
23606        <div class="stat-chip" data-raw="{{ code_lines }}">
23607          <div class="stat-chip-label">Code</div>
23608          <div class="stat-chip-val">{{ code_lines }}</div>
23609          <div class="stat-chip-exact"></div>
23610          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23611        </div>
23612        <div class="stat-chip" data-raw="{{ comment_lines }}">
23613          <div class="stat-chip-label">Comments</div>
23614          <div class="stat-chip-val">{{ comment_lines }}</div>
23615          <div class="stat-chip-exact"></div>
23616          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23617        </div>
23618        <div class="stat-chip" data-raw="{{ blank_lines }}">
23619          <div class="stat-chip-label">Blank</div>
23620          <div class="stat-chip-val">{{ blank_lines }}</div>
23621          <div class="stat-chip-exact"></div>
23622          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23623        </div>
23624        <div class="stat-chip" data-raw="{{ mixed_lines }}">
23625          <div class="stat-chip-label">Mixed separate</div>
23626          <div class="stat-chip-val">{{ mixed_lines }}</div>
23627          <div class="stat-chip-exact"></div>
23628          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23629        </div>
23630        <div class="stat-chip" data-raw="{{ functions }}">
23631          <div class="stat-chip-label">Functions</div>
23632          <div class="stat-chip-val">{{ functions }}</div>
23633          <div class="stat-chip-exact"></div>
23634          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23635        </div>
23636        <div class="stat-chip" data-raw="{{ classes }}">
23637          <div class="stat-chip-label">Classes / Types</div>
23638          <div class="stat-chip-val">{{ classes }}</div>
23639          <div class="stat-chip-exact"></div>
23640          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23641        </div>
23642        <div class="stat-chip" data-raw="{{ variables }}">
23643          <div class="stat-chip-label">Variables</div>
23644          <div class="stat-chip-val">{{ variables }}</div>
23645          <div class="stat-chip-exact"></div>
23646          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23647        </div>
23648        <div class="stat-chip" data-raw="{{ imports }}">
23649          <div class="stat-chip-label">Imports</div>
23650          <div class="stat-chip-val">{{ imports }}</div>
23651          <div class="stat-chip-exact"></div>
23652          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23653        </div>
23654        <div class="stat-chip" data-raw="{{ test_count }}">
23655          <div class="stat-chip-label">Tests</div>
23656          <div class="stat-chip-val">{{ test_count }}</div>
23657          <div class="stat-chip-exact"></div>
23658          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23659        </div>
23660        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23661          <div class="stat-chip-label">Code density</div>
23662          <div class="stat-chip-val stat-chip-density-val">—</div>
23663          <div class="stat-chip-exact"></div>
23664          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23665        </div>
23666        <div class="stat-chip" data-raw="{{ files_analyzed }}">
23667          <div class="stat-chip-label">Files analyzed</div>
23668          <div class="stat-chip-val">{{ files_analyzed }}</div>
23669          <div class="stat-chip-exact"></div>
23670          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23671        </div>
23672        {% if cyclomatic_complexity > 0 %}
23673        <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 %}>
23674          <div class="stat-chip-label">Complexity score</div>
23675          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23676          <div class="stat-chip-exact"></div>
23677          <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>
23678        </div>
23679        {% endif %}
23680        {% if let Some(ls) = lsloc %}
23681        <div class="stat-chip" data-raw="{{ ls }}">
23682          <div class="stat-chip-label">Logical SLOC</div>
23683          <div class="stat-chip-val">{{ ls }}</div>
23684          <div class="stat-chip-exact"></div>
23685          <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>
23686        </div>
23687        {% endif %}
23688        {% if uloc > 0 %}
23689        <div class="stat-chip" data-raw="{{ uloc }}">
23690          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23691          <div class="stat-chip-val">{{ uloc }}</div>
23692          <div class="stat-chip-exact"></div>
23693          <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>
23694        </div>
23695        {% endif %}
23696        {% if uloc > 0 && dryness_pct_str != "" %}
23697        <div class="stat-chip">
23698          <div class="stat-chip-label">DRYness</div>
23699          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23700          <div class="stat-chip-exact"></div>
23701          <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>
23702        </div>
23703        {% endif %}
23704        {% if duplicate_group_count > 0 %}
23705        <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23706          <div class="stat-chip-label">Duplicate groups</div>
23707          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23708          <div class="stat-chip-exact"></div>
23709          <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>
23710        </div>
23711        {% endif %}
23712        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23713             odd, so the strip always forms exactly two full rows with every column
23714             aligned and every card the same width (no oversized card, no gap). -->
23715        <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23716          <div class="stat-chip-label">Assertions</div>
23717          <div class="stat-chip-val">{{ test_assertion_count }}</div>
23718          <div class="stat-chip-exact"></div>
23719          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23720        </div>
23721      </div>
23722
23723      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23724      <div class="compare-banner">
23725        <div class="compare-banner-body">
23726          <div class="compare-banner-top">
23727          <div class="compare-banner-meta">
23728            <span class="compare-label">Previous scan</span>
23729            <span class="compare-ts">{{ prev_ts }}</span>
23730            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23731            {% if let Some(prev_code) = prev_run_code_lines %}
23732            <div class="compare-banner-stats" style="margin-top:4px;">
23733              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23734              <span class="compare-arrow">→</span>
23735              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23736              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23737              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23738            </div>
23739            {% endif %}
23740          </div>
23741          {% if delta_lines_added.is_some() %}
23742          <div class="delta-cards-inline">
23743            <div class="delta-card-inline">
23744              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23745              <div class="delta-card-lbl">lines added</div>
23746              <div class="delta-card-tip">Code lines added since the previous scan</div>
23747            </div>
23748            <div class="delta-card-inline">
23749              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
23750              <div class="delta-card-lbl">lines removed</div>
23751              <div class="delta-card-tip">Code lines removed since the previous scan</div>
23752            </div>
23753            <div class="delta-card-inline">
23754              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23755              <div class="delta-card-lbl">unmodified lines</div>
23756              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23757            </div>
23758            <div class="delta-card-inline">
23759              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23760              <div class="delta-card-lbl">files modified</div>
23761              <div class="delta-card-tip">Files with at least one line changed</div>
23762            </div>
23763            <div class="delta-card-inline">
23764              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23765              <div class="delta-card-lbl">files added</div>
23766              <div class="delta-card-tip">New files added since the previous scan</div>
23767            </div>
23768            <div class="delta-card-inline">
23769              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23770              <div class="delta-card-lbl">files removed</div>
23771              <div class="delta-card-tip">Files deleted since the previous scan</div>
23772            </div>
23773            <div class="delta-card-inline">
23774              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23775              <div class="delta-card-lbl">files unchanged</div>
23776              <div class="delta-card-tip">Files with no changes since the previous scan</div>
23777            </div>
23778          </div>
23779          {% else %}
23780          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23781            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23782          </p>
23783          {% endif %}
23784          </div>
23785          <div class="compare-banner-actions">
23786            <div class="compare-banner-actions-left">
23787              <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23788              <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23789            </div>
23790            <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23791          </div>
23792        </div>
23793      </div>
23794      {% endif %}{% endif %}
23795
23796      <div class="action-grid">
23797        <div class="action-card">
23798          <h3>HTML report</h3>
23799          <div class="action-buttons">
23800            {% match html_url %}
23801              {% when Some with (url) %}
23802                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23803              {% when None %}{% endmatch %}
23804            {% match html_download_url %}
23805              {% when Some with (url) %}
23806                <a class="button secondary" href="{{ url }}">Download HTML</a>
23807              {% when None %}{% endmatch %}
23808            {% match html_path %}
23809              {% when Some with (_path) %}{% when None %}{% endmatch %}
23810            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23811          </div>
23812        </div>
23813        <div class="action-card">
23814          <h3>PDF report</h3>
23815          <div class="action-buttons">
23816            {% match pdf_url %}
23817              {% when Some with (url) %}
23818                {% if pdf_generating %}
23819                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23820                    <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>
23821                    Generating PDF…
23822                  </button>
23823                {% else %}
23824                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23825                {% endif %}
23826              {% when None %}
23827                {% match html_url %}
23828                  {% when Some with (_hurl) %}
23829                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23830                    <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>
23831                  {% when None %}
23832                    <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;">
23833                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23834                    </p>
23835                {% endmatch %}
23836            {% endmatch %}
23837            {% match pdf_download_url %}
23838              {% when Some with (url) %}
23839                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23840              {% when None %}{% endmatch %}
23841            {% match pdf_url %}
23842              {% when Some with (_) %}
23843                <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23844              {% when None %}{% endmatch %}
23845          </div>
23846        </div>
23847        <div class="action-card">
23848          <h3>JSON result</h3>
23849          <div class="action-buttons">
23850            {% match json_url %}
23851              {% when Some with (url) %}
23852                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23853              {% when None %}{% endmatch %}
23854            {% match json_download_url %}
23855              {% when Some with (url) %}
23856                <a class="button secondary" href="{{ url }}">Download JSON</a>
23857              {% when None %}{% endmatch %}
23858            {% match json_path %}
23859              {% when Some with (_path) %}
23860                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23861              {% when None %}
23862                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23863              {% endmatch %}
23864          </div>
23865        </div>
23866        <div class="action-card">
23867          <h3>Scan config</h3>
23868          <div class="action-buttons">
23869            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23870            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23871            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23872          </div>
23873        </div>
23874        {% if confluence_configured %}
23875        <div class="action-card" id="confluenceCard">
23876          <h3>Confluence</h3>
23877          <div class="action-buttons">
23878            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23879            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23880          </div>
23881          <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>
23882        </div>
23883        {% endif %}
23884      </div>
23885      {% if confluence_configured %}
23886      <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;">
23887        <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);">
23888          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23889          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23890          <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;">
23891          <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>
23892          <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;">
23893          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23894          <div style="display:flex;gap:10px;justify-content:flex-end;">
23895            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23896            <button class="button" id="confSubmitBtn" type="button">Post</button>
23897          </div>
23898        </div>
23899      </div>
23900      {% endif %}
23901      <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;">
23902        <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);">
23903          <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run &mdash; irreversible</div>
23904          <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>
23905          <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23906          <div style="display:flex;gap:18px;justify-content:flex-end;">
23907            <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23908            <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>
23909          </div>
23910        </div>
23911      </div>
23912      {% if !submodule_rows.is_empty() %}
23913      <div class="submodule-panel">
23914        <div class="toolbar-row">
23915          <div>
23916            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23917            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23918          </div>
23919          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23920        </div>
23921        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23922        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23923          <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>
23924          <thead>
23925            <tr>
23926              <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>
23927              <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>
23928              <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>
23929              <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>
23930              <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>
23931              <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>
23932              <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>
23933              <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>
23934            </tr>
23935          </thead>
23936          <tbody>
23937            {% for row in submodule_rows %}
23938            <tr>
23939              <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>
23940              <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>
23941              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23942              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23943              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23944              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23945              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23946              <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>
23947            </tr>
23948            {% endfor %}
23949          </tbody>
23950        </table>
23951        </div>
23952      </div>
23953      {% endif %}
23954
23955      <div class="metrics-tables-stack">
23956
23957        <div class="metrics-table-wrap">
23958          <div class="metrics-table-title">Files</div>
23959          <table class="metrics-table">
23960            <thead>
23961              <tr>
23962                <th>Metric</th>
23963                <th>This Run</th>
23964                <th>Previous</th>
23965                <th>Change</th>
23966              </tr>
23967            </thead>
23968            <tbody>
23969              <tr>
23970                <td>Files analyzed</td>
23971                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23972                <td>{{ prev_fa_str|commas }}</td>
23973                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23974              </tr>
23975              <tr>
23976                <td>Files skipped</td>
23977                <td>{{ files_skipped|commas }}</td>
23978                <td>{{ prev_fs_str|commas }}</td>
23979                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23980              </tr>
23981              <tr>
23982                <td>Files modified</td>
23983                <td class="mt-val-na">—</td>
23984                <td class="mt-val-na">—</td>
23985                <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>
23986              </tr>
23987              <tr>
23988                <td>Files unchanged</td>
23989                <td class="mt-val-na">—</td>
23990                <td class="mt-val-na">—</td>
23991                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23992              </tr>
23993            </tbody>
23994          </table>
23995        </div>
23996
23997        <div class="metrics-table-wrap">
23998          <div class="metrics-table-title">Line Counts</div>
23999          <table class="metrics-table">
24000            <thead>
24001              <tr>
24002                <th>Metric</th>
24003                <th>This Run</th>
24004                <th>Previous</th>
24005                <th>Change</th>
24006              </tr>
24007            </thead>
24008            <tbody>
24009              <tr>
24010                <td>Physical lines</td>
24011                <td class="mt-val-large">{{ physical_lines|commas }}</td>
24012                <td>{{ prev_pl_str|commas }}</td>
24013                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24014              </tr>
24015              <tr>
24016                <td>Code lines</td>
24017                <td class="mt-val-large">{{ code_lines|commas }}</td>
24018                <td>{{ prev_cl_str|commas }}</td>
24019                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24020              </tr>
24021              <tr>
24022                <td>Comment lines</td>
24023                <td>{{ comment_lines|commas }}</td>
24024                <td>{{ prev_cml_str|commas }}</td>
24025                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24026              </tr>
24027              <tr>
24028                <td>Blank lines</td>
24029                <td>{{ blank_lines|commas }}</td>
24030                <td>{{ prev_bl_str|commas }}</td>
24031                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24032              </tr>
24033              <tr>
24034                <td>Mixed (separate)</td>
24035                <td>{{ mixed_lines|commas }}</td>
24036                <td class="mt-val-na">—</td>
24037                <td class="mt-val-na">—</td>
24038              </tr>
24039            </tbody>
24040          </table>
24041        </div>
24042
24043        <div class="metrics-tables-lower">
24044          <div class="metrics-table-wrap">
24045            <div class="metrics-table-title">Code Structure</div>
24046            <table class="metrics-table">
24047              <thead>
24048                <tr>
24049                  <th>Metric</th>
24050                  <th>This Run</th>
24051                </tr>
24052              </thead>
24053              <tbody>
24054                <tr>
24055                  <td>Functions</td>
24056                  <td>{{ functions|commas }}</td>
24057                </tr>
24058                <tr>
24059                  <td>Classes / Types</td>
24060                  <td>{{ classes|commas }}</td>
24061                </tr>
24062                <tr>
24063                  <td>Variables</td>
24064                  <td>{{ variables|commas }}</td>
24065                </tr>
24066                <tr>
24067                  <td>Imports</td>
24068                  <td>{{ imports|commas }}</td>
24069                </tr>
24070              </tbody>
24071            </table>
24072          </div>
24073
24074          <div class="metrics-table-wrap">
24075            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24076            <table class="metrics-table">
24077              <thead>
24078                <tr>
24079                  <th>Metric</th>
24080                  <th>Change</th>
24081                </tr>
24082              </thead>
24083              <tbody>
24084                <tr>
24085                  <td>Lines added</td>
24086                  <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>
24087                </tr>
24088                <tr>
24089                  <td>Lines removed</td>
24090                  <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>
24091                </tr>
24092                <tr>
24093                  <td>Lines modified (net)</td>
24094                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24095                </tr>
24096                <tr>
24097                  <td>Lines unmodified</td>
24098                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24099                </tr>
24100              </tbody>
24101            </table>
24102          </div>
24103        </div>
24104
24105      </div>
24106
24107      <div class="path-list">
24108        <div class="path-item">
24109          <div class="path-item-label">Project path</div>
24110          {% 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 %}
24111        </div>
24112        <div class="path-item">
24113          <div class="path-item-label">Git branch</div>
24114          {% if let Some(branch) = git_branch %}
24115          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24116          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24117          {% else %}
24118          <code style="color:var(--muted)">—</code>
24119          {% endif %}
24120        </div>
24121        <div class="path-item">
24122          <div class="path-item-label">Output folder</div>
24123          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24124        </div>
24125        <div class="path-item">
24126          <div class="path-item-label">Run ID</div>
24127          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24128            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24129            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24130          </div>
24131        </div>
24132      </div>
24133    </section>
24134
24135    {% if has_cocomo %}
24136    <div class="cocomo-box" style="margin-top:24px;">
24137      <div class="cocomo-box-head">
24138        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
24139        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24140          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24141          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24142        </span>
24143      </div>
24144      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24145        <div class="stat-chip">
24146          <div class="stat-chip-label">Person-months</div>
24147          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24148          <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>
24149        </div>
24150        <div class="stat-chip">
24151          <div class="stat-chip-label">Schedule (months)</div>
24152          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24153          <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>
24154        </div>
24155        <div class="stat-chip">
24156          <div class="stat-chip-label">Avg. Team Size</div>
24157          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24158          <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>
24159        </div>
24160        <div class="stat-chip">
24161          <div class="stat-chip-label">Input KSLOC</div>
24162          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24163          <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>
24164        </div>
24165      </div>
24166      <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>
24167    </div>
24168    {% endif %}
24169
24170    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24171    <div class="cocomo-box" style="margin-top:24px;">
24172      <div class="cocomo-box-head">
24173        <span class="cocomo-box-title">Tests &amp; Coverage</span>
24174        {% if has_coverage_data %}
24175        <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24176          <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24177        </span>
24178        {% endif %}
24179      </div>
24180      <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24181        <div class="stat-chip">
24182          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24183          <div class="stat-chip-label">Test Functions</div>
24184          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24185        </div>
24186        <div class="stat-chip">
24187          {% if has_coverage_data %}
24188          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24189          {% else %}
24190          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24191          {% endif %}
24192          <div class="stat-chip-label">Line Coverage</div>
24193          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24194        </div>
24195        <div class="stat-chip">
24196          {% if !cov_fn_pct.is_empty() %}
24197          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24198          {% else %}
24199          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24200          {% endif %}
24201          <div class="stat-chip-label">Fn Coverage</div>
24202          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24203        </div>
24204        <div class="stat-chip">
24205          {% if !cov_branch_pct.is_empty() %}
24206          <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24207          {% else %}
24208          <div class="stat-chip-val" style="color:var(--muted);">&mdash;</div>
24209          {% endif %}
24210          <div class="stat-chip-label">Branch Coverage</div>
24211          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24212        </div>
24213      </div>
24214      {% if has_coverage_data %}
24215      <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>
24216      {% else %}
24217      <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>
24218      {% endif %}
24219    </div>
24220
24221    <div class="section-pair">
24222    <section class="panel">
24223        <div class="toolbar-row">
24224          <div>
24225            <h2>Language Breakdown</h2>
24226            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24227          </div>
24228          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24229        </div>
24230        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24231    </section>
24232
24233    <section class="panel r-chart-section">
24234      <div class="toolbar-row" style="margin-bottom:16px;">
24235        <div>
24236          <h2>Visualizations</h2>
24237          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24238        </div>
24239      </div>
24240
24241      <div class="r-viz-grid">
24242        <div class="r-viz-card">
24243          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24244            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24245            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24246          </div>
24247          <div class="r-chart-tab-bar">
24248            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24249            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24250          </div>
24251          <div class="r-chart-container" id="r-composition-chart"></div>
24252        </div>
24253        <div class="r-viz-card">
24254          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24255            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24256            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24257          </div>
24258          <div class="r-chart-container" id="r-scatter-chart"></div>
24259        </div>
24260        {% if has_semantic_data %}
24261        <div class="r-viz-card">
24262          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24263            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24264            <select class="r-chart-select" id="r-semantic-metric">
24265              <option value="functions">Functions</option>
24266              <option value="classes">Classes</option>
24267              <option value="variables">Variables</option>
24268              <option value="imports">Imports</option>
24269              <option value="tests">Tests</option>
24270            </select>
24271            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24272          </div>
24273          <div class="r-chart-container" id="r-semantic-chart"></div>
24274        </div>
24275        {% endif %}
24276        <div class="r-viz-card">
24277          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24278            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24279            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24280          </div>
24281          <div class="r-chart-container" id="r-density-chart"></div>
24282        </div>
24283        <div class="r-viz-card">
24284          <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24285            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24286            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24287          </div>
24288          <div class="r-chart-container" id="r-avglines-chart"></div>
24289        </div>
24290        <div class="r-viz-card">
24291          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24292            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24293            <select class="r-chart-select" id="r-sub-metric">
24294              <option value="code">Code Lines</option>
24295              <option value="comment">Comments</option>
24296              <option value="blank">Blank Lines</option>
24297              <option value="physical">Physical Lines</option>
24298              <option value="files">Files</option>
24299            </select>
24300            <select class="r-chart-select" id="r-sub-sort">
24301              <option value="desc">Value ↓</option>
24302              <option value="asc">Value ↑</option>
24303              <option value="name">Name A→Z</option>
24304            </select>
24305            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
24306          </div>
24307          <div class="r-chart-container" id="r-submodule-chart"></div>
24308        </div>
24309      </div>
24310
24311    </section>
24312    </div>
24313
24314  </div>
24315
24316  <div id="r-tt" aria-hidden="true"></div>
24317
24318  <script nonce="{{ csp_nonce }}">
24319    (function () {
24320      var body = document.body;
24321      var themeToggle = document.getElementById('theme-toggle');
24322      var storageKey = 'oxide-sloc-theme';
24323
24324      function applyTheme(theme) {
24325        body.classList.toggle('dark-theme', theme === 'dark');
24326      }
24327
24328      function loadSavedTheme() {
24329        try {
24330          var saved = localStorage.getItem(storageKey);
24331          if (saved === 'dark' || saved === 'light') {
24332            applyTheme(saved);
24333          }
24334        } catch (e) {}
24335      }
24336
24337      if (themeToggle) {
24338        themeToggle.addEventListener('click', function () {
24339          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24340          applyTheme(nextTheme);
24341          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24342        });
24343      }
24344
24345      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24346        button.addEventListener('click', function () {
24347          var value = button.getAttribute('data-copy-value') || '';
24348          if (!value) return;
24349          var originalText = button.textContent;
24350          function flashSuccess() {
24351            button.textContent = 'Copied!';
24352            setTimeout(function () { button.textContent = originalText; }, 1800);
24353          }
24354          function flashFail() {
24355            button.textContent = 'Copy failed';
24356            setTimeout(function () { button.textContent = originalText; }, 2000);
24357          }
24358          if (navigator.clipboard && navigator.clipboard.writeText) {
24359            navigator.clipboard.writeText(value).then(flashSuccess, function () {
24360              fallbackCopy(value, flashSuccess, flashFail);
24361            });
24362          } else {
24363            fallbackCopy(value, flashSuccess, flashFail);
24364          }
24365        });
24366      });
24367      function fallbackCopy(text, onSuccess, onFail) {
24368        try {
24369          var ta = document.createElement('textarea');
24370          ta.value = text;
24371          ta.style.position = 'fixed';
24372          ta.style.top = '-9999px';
24373          ta.style.left = '-9999px';
24374          document.body.appendChild(ta);
24375          ta.focus();
24376          ta.select();
24377          var ok = document.execCommand('copy');
24378          document.body.removeChild(ta);
24379          if (ok) { onSuccess(); } else { onFail(); }
24380        } catch (e) { onFail(); }
24381      }
24382
24383      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24384        btn.addEventListener('click', function () {
24385          var folder = btn.getAttribute('data-folder') || '';
24386          if (!folder) return;
24387          var orig = btn.textContent;
24388          fetch('/open-path?path=' + encodeURIComponent(folder))
24389            .then(function (r) { return r.json(); })
24390            .then(function (d) {
24391              if (d && d.server_mode_disabled) {
24392                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24393              } else if (d && d.ok) {
24394                btn.textContent = 'Opened!';
24395                setTimeout(function () { btn.textContent = orig; }, 1800);
24396              }
24397            })
24398            .catch(function () {
24399              btn.textContent = 'Failed';
24400              setTimeout(function () { btn.textContent = orig; }, 2000);
24401            });
24402        });
24403      });
24404
24405      loadSavedTheme();
24406
24407      // ── Compact number formatting for stat chips ──────────────────────────
24408      (function(){
24409        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();}
24410        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24411          var raw=parseInt(chip.getAttribute('data-raw'),10);
24412          if(isNaN(raw))return;
24413          var valEl=chip.querySelector('.stat-chip-val');
24414          if(valEl)valEl.textContent=fmt(raw);
24415          var exactEl=chip.querySelector('.stat-chip-exact');
24416          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24417        });
24418        // Code density chip
24419        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24420          var code=parseInt(chip.getAttribute('data-code'),10);
24421          var phys=parseInt(chip.getAttribute('data-physical'),10);
24422          if(isNaN(code)||isNaN(phys)||phys===0)return;
24423          var pct=(code/phys*100).toFixed(1)+'%';
24424          var valEl=chip.querySelector('.stat-chip-val');
24425          if(valEl)valEl.textContent=pct;
24426        });
24427        // Populate author handle from data-author attribute
24428        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24429          var author=chip.getAttribute('data-author');
24430          var el=chip.querySelector('.author-handle');
24431          if(el)el.textContent='/'+author.replace(/\s+/g,'');
24432        });
24433        // Click-to-copy on run-id-chip elements
24434        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24435          chip.addEventListener('click',function(){
24436            var val=chip.getAttribute('data-copy');
24437            if(!val)return;
24438            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24439            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);}
24440            chip.classList.add('chip-copied-flash');
24441            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24442          });
24443        });
24444        // Format delta card values with data-raw using comma-separated full numbers
24445        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24446          var raw=parseInt(card.getAttribute('data-raw'),10);
24447          if(isNaN(raw))return;
24448          var valEl=card.querySelector('.delta-card-val');
24449          if(valEl)valEl.textContent=raw.toLocaleString();
24450        });
24451        // Format code-before / code-now numbers in the compare banner stats line
24452        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24453          var raw=parseInt(el.getAttribute('data-raw'),10);
24454          if(!isNaN(raw))el.textContent=raw.toLocaleString();
24455        });
24456      })();
24457
24458      // ── Shared tooltip for all result-page charts ─────────────────────────
24459      var rTT=(function(){
24460        var el=document.getElementById('r-tt');
24461        if(!el)return{s:function(){},h:function(){},m:function(){}};
24462        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24463        function hide(){el.style.display='none';}
24464        function move(e){
24465          var x=e.clientX+16,y=e.clientY-12;
24466          var r=el.getBoundingClientRect();
24467          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24468          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24469          el.style.left=x+'px';el.style.top=y+'px';
24470        }
24471        return{s:show,h:hide,m:move};
24472      })();
24473      window.rTT=rTT;
24474
24475      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24476      (function(){
24477        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24478        document.addEventListener('mouseover',function(e){
24479          var t=e.target;
24480          while(t&&t.getAttribute){
24481            var l=t.getAttribute('data-ttl');
24482            if(l!==null){
24483              var v=t.getAttribute('data-ttv')||'';
24484              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24485              return;
24486            }
24487            t=t.parentNode;
24488          }
24489        });
24490        document.addEventListener('mouseout',function(e){
24491          var t=e.target;
24492          while(t&&t.getAttribute){
24493            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24494            t=t.parentNode;
24495          }
24496        });
24497        document.addEventListener('mousemove',function(e){
24498          var el=document.getElementById('r-tt');
24499          if(el&&el.style.display!=='none')rTT.m(e);
24500        });
24501        window.addEventListener('blur',function(){rTT.h();});
24502        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24503      })();
24504
24505      // ── Language overview charts ───────────────────────────────────────────
24506      (function(){
24507        var D={{ lang_chart_json|safe }};
24508        if(!D||!D.length)return;
24509        var el=document.getElementById('result-lang-charts');
24510        if(!el)return;
24511        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24512        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24513        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24514        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();}
24515        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24516        function px(n){return Math.round(n);}
24517        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+'"';}
24518        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24519        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24520        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24521        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;}
24522        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24523
24524        // Donut chart — height matches the stacked-bar chart so both panels align
24525        var rHb_d=28;
24526        var DH=Math.max(220,D.length*rHb_d+32);
24527        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24528        var legX=208,DW=395;
24529        var legCount=D.length;
24530        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24531        var legYStart=Math.round((DH-legCount*legSpacing)/2);
24532        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">';
24533        // One shared transition on every donut element so slices, leader lines,
24534        // outside labels, % labels and the legend all animate together as a single
24535        // picture when a language is hovered. Slices scale from the donut centre.
24536        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>';
24537        if(D.length===1){
24538          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24539          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+'"/>';
24540        } else {
24541          var smalls=[];
24542          var ang=-Math.PI/2;
24543          D.forEach(function(d,i){
24544            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24545            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24546            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24547            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24548            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24549            var pct=Math.round(d.code/tot*100);
24550            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"/>';
24551            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]});}
24552            ang+=sw;
24553          });
24554          // Small slices (<5%) get outside labels positioned near each slice's own
24555          // angular position (a slice on the left gets its label/leader on the left),
24556          // then nudged apart horizontally so text never overlaps. Leader lines point
24557          // from each slice to its label. Horizontal text keeps long names legible;
24558          // the whole SVG scales up in Full View so these stay readable there too.
24559          if(smalls.length){
24560            smalls.sort(function(a,b){return a.mAng-b.mAng;});
24561            var sPad=6,sRowY=11;
24562            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)));});
24563            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;}
24564            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24565            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24566            smalls.forEach(function(sm){
24567              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24568              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;"/>';
24569              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>';
24570            });
24571          }
24572        }
24573        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24574        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24575        D.forEach(function(d,i){
24576          var ly=legYStart+i*legSpacing;
24577          var pctL=Math.round(d.code/tot*100);
24578          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24579          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
24580          ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24581          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24582          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24583          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24584          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>';
24585          ds+='</g>';
24586        });
24587        ds+='</svg>';
24588
24589        // Horizontal stacked-bar chart — fills container width
24590        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24591        var LW=108,BW=260,svgW=LW+BW+68;
24592        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24593        var barBH=Math.min(32,Math.round(barRhb*0.7));
24594        var SH=DH;
24595        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24596        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">';
24597        D.forEach(function(d,i){
24598          var y=barTopPad+i*barRhb,x=LW;
24599          var phys=d.physical||d.code+d.comments+d.blanks;
24600          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24601          var lmid=y+barBH/2+4;
24602          // Combined breakdown shown when hovering the row, the language name, or the
24603          // total at the bar end (\n becomes a line break in the tooltip).
24604          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24605          bs+='<g class="lang-bar-row">';
24606          // Hit area ends just past the total label so empty space to the right of the
24607          // bar does not trigger the tooltip — only the name, bar and total are hot.
24608          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24609          bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24610          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>';
24611          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;}
24612          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;}
24613          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>';}
24614          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>';
24615          bs+='</g>';
24616        });
24617        var ly=SH-14;
24618        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24619        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24620        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24621        var totAll=totC+totCm+totBl||1;
24622        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24623        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24624        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24625        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24626        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24627        bs+='<g data-kind="code" style="cursor:pointer;">'
24628          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24629          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24630          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24631          +'</g>';
24632        bs+='<g data-kind="comment" style="cursor:pointer;">'
24633          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24634          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24635          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24636          +'</g>';
24637        bs+='<g data-kind="blank" style="cursor:pointer;">'
24638          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24639          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24640          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24641          +'</g>';
24642        bs+='</svg>';
24643        el.innerHTML='<div class="r-lang-overview">'+
24644          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24645          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24646        '</div>';
24647        function wireDonutLegend(svg){
24648          if(!svg)return;
24649          // Every donut element carries data-lang: slices (path/circle), leader lines,
24650          // outside labels + % labels (text) and legend rows (g). Hovering any one of
24651          // them emphasises that language across all of them and fades the rest, so the
24652          // slice, its leader line, its label and its legend row move as one picture.
24653          var items=svg.querySelectorAll('[data-lang]');
24654          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24655            var tag=el.tagName.toLowerCase();
24656            if(tag==='path'||tag==='circle'){
24657              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)';}
24658              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24659              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24660            }else if(tag==='line'){
24661              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24662              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24663              else{el.style.opacity='';el.style.strokeWidth='';}
24664            }else if(tag==='text'){
24665              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24666              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24667              else{el.style.opacity='';el.style.fontWeight='';}
24668            }else{ // legend group
24669              if(st===1){el.style.opacity='1';}
24670              else if(st===-1){el.style.opacity='0.4';}
24671              else{el.style.opacity='';}
24672            }
24673          }
24674          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24675          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24676          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();});
24677          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();});
24678          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24679        }
24680        function wireMixLegend(svg){
24681          if(!svg)return;
24682          var legGs=svg.querySelectorAll('g[data-kind]');
24683          var allRects=svg.querySelectorAll('rect[data-kind]');
24684          if(!legGs.length)return;
24685          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';}}
24686          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='';}}
24687          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]);}
24688        }
24689        wireDonutLegend(el.querySelector('svg'));
24690        wireMixLegend(el.querySelectorAll('svg')[1]);
24691
24692        // ── Language breakdown Full View expand ─────────────────────────────────
24693        var langOvBtn=document.getElementById('result-lang-overview-expand');
24694        if(langOvBtn){langOvBtn.addEventListener('click',function(){
24695          var src=document.getElementById('result-lang-charts');
24696          if(!src)return;
24697          var overlay=document.createElement('div');
24698          overlay.className='r-chart-modal-overlay';
24699          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>';
24700          document.body.appendChild(overlay);
24701          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24702          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24703          var wrap=document.getElementById('result-lang-overview-modal-wrap');
24704          if(wrap){
24705            wrap.innerHTML=src.innerHTML;
24706            var svgs=wrap.querySelectorAll('svg');
24707            for(var i=0;i<svgs.length;i++){
24708              svgs[i].removeAttribute('width');
24709              svgs[i].removeAttribute('height');
24710              svgs[i].style.cssText='display:block;width:100%;height:auto;';
24711            }
24712            var ov=wrap.querySelector('.r-lang-overview');
24713            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24714            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24715            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24716            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24717            wireDonutLegend(wrap.querySelector('svg'));
24718            wireMixLegend(wrap.querySelectorAll('svg')[1]);
24719            requestAnimationFrame(function(){
24720              var ss=wrap.querySelectorAll('svg');
24721              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%;';}}
24722            });
24723          }
24724        });}
24725      })();
24726
24727      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24728      (function(){
24729        var LANG_D={{ lang_chart_json|safe }};
24730        var SCAT_D={{ scatter_chart_json|safe }};
24731        var SEM_D={{ semantic_chart_json|safe }};
24732        var SUB_D={{ submodule_chart_json|safe }};
24733        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24734        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24735        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();}
24736        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
24737        function px(n){return Math.round(n);}
24738        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+'"';}
24739        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24740        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24741        // rather than disappear; the SVG scales up in Full View).
24742        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;}
24743
24744        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24745        function renderCompositionInEl(el,mode,shOvr){
24746          if(!el||!LANG_D||!LANG_D.length)return;
24747          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24748          var LW=110,SH=shOvr||300;
24749          var svgW=Math.max(320,el.offsetWidth||480);
24750          var BW=Math.max(120,svgW-LW-80);
24751          var legendH=24,topPad=4;
24752          var n=LANG_D.length||1;
24753          var rowTotal=Math.floor((SH-legendH-topPad)/n);
24754          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24755          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">';
24756          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24757          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24758          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24759          var totAll2=totC2+totCm2+totBl2||1;
24760          if(mode==='pct'){
24761            LANG_D.forEach(function(d,i){
24762              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24763              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24764              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24765              var lmid=y+Math.floor(bH/2)+4;
24766              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24767              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>';
24768              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;}
24769              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;}
24770              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>';}
24771              var pct=Math.round((d.code||0)/tot2*100);
24772              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>';
24773            });
24774          } else {
24775            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24776            LANG_D.forEach(function(d,i){
24777              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24778              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24779              var lmid=y+Math.floor(bH/2)+4;
24780              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));
24781              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>';
24782              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;}
24783              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;}
24784              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>';}
24785              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>';
24786            });
24787          }
24788          var ly=SH-legendH+4;
24789          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24790          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
24791          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24792          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24793          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24794          s+='<g data-kind="code" style="cursor:pointer;">'
24795            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24796            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24797            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24798            +'</g>';
24799          s+='<g data-kind="comment" style="cursor:pointer;">'
24800            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24801            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24802            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24803            +'</g>';
24804          s+='<g data-kind="blank" style="cursor:pointer;">'
24805            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24806            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24807            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24808            +'</g>';
24809          s+='</svg>';
24810          el.innerHTML=s;
24811          wireMixLegendEl(el);
24812        }
24813        function wireMixLegendEl(container){
24814          var svg=container&&container.querySelector('svg');
24815          if(!svg)return;
24816          var legGs=svg.querySelectorAll('g[data-kind]');
24817          var allRects=svg.querySelectorAll('rect[data-kind]');
24818          if(!legGs.length)return;
24819          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';}}
24820          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='';}}
24821          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]);}
24822        }
24823        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24824        renderComposition('abs');
24825        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24826          btn.addEventListener('click',function(){
24827            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24828            btn.classList.add('active');
24829            renderComposition(btn.getAttribute('data-rcomp'));
24830          });
24831        });
24832
24833        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24834        function wireScatterLegend(container){
24835          var svg=container&&container.querySelector('svg');
24836          if(!svg)return;
24837          var legGs=svg.querySelectorAll('g[data-lang]');
24838          var circs=svg.querySelectorAll('circle[data-lang]');
24839          var labs=svg.querySelectorAll('text[data-lang]');
24840          if(!legGs.length)return;
24841          // Raise an element to the top of its parent so the hovered bubble and its
24842          // name/number labels sit above overlapping neighbours (clustered bubbles
24843          // otherwise bury the one you are trying to read).
24844          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24845          function hl(lang){
24846            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';}}
24847            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';}}
24848            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24849          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='';}}
24850          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]);}
24851        }
24852        function renderScatterInEl(el,hOvr){
24853          if(!el||!SCAT_D||!SCAT_D.length)return;
24854          var n=SCAT_D.length;
24855          var H=hOvr||300,PL=52,PB=36,PT=44;
24856          var W=Math.max(320,el.offsetWidth||480);
24857          var cH=H-PT-PB;
24858          // Legend: max 2 columns, fills vertical space. The compact card shows the
24859          // top languages by code lines plus a "+N more" row linking to Full View;
24860          // Full View (hOvr set) shows every language across up to 2 tall columns.
24861          var compact=!hOvr;
24862          var availH=Math.max(120,H-24);
24863          var rowsFit=Math.max(2,Math.floor(availH/18));
24864          var legTrunc=compact&&(n>2*rowsFit);
24865          var legShown=legTrunc?(2*rowsFit-1):n;
24866          var legTotal=legTrunc?(2*rowsFit):n;
24867          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24868          var legPerCol=Math.ceil(legTotal/legCols);
24869          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24870          var legColW=hOvr?144:130;
24871          var LG=26;
24872          var legW=legCols*legColW;
24873          var cW=W-PL-LG-legW;
24874          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24875          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24876          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24877          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24878          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24879          var logMaxF=Math.log1p(maxF);
24880          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">';
24881          // Smooth the legend-hover fade so bubbles + labels animate together.
24882          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24883          // Y grid lines (linear)
24884          [0,0.25,0.5,0.75,1].forEach(function(t){
24885            var y=PT+cH*(1-t);
24886            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24887            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>';
24888          });
24889          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24890          [0,0.25,0.5,0.75,1].forEach(function(t){
24891            var x=PL+cW*t;
24892            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24893            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24894            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>';
24895          });
24896          // Full View (hOvr set) has the vertical room to show the per-bubble value
24897          // line; the compact card shows only the language label to avoid the
24898          // overlapping-label clutter seen when bubbles cluster together.
24899          var showVal=!!hOvr;
24900          SCAT_D.forEach(function(d,i){
24901            // X uses log1p so outlier languages (many files) don't push others to the far left
24902            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24903            var cy2=PT+cH-d.code/maxC*cH;
24904            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24905            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"/>';
24906            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24907            if(showVal){
24908              var ty2=Math.max(24,px(cy2)-px(r)-3);
24909              var ty1=Math.max(12,ty2-14);
24910              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>';
24911              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>';
24912            }else{
24913              var ly2=Math.max(12,px(cy2)-px(r)-3);
24914              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>';
24915            }
24916          });
24917          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>';
24918          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>';
24919          // Legend (right side — top languages, max 2 columns, fills height)
24920          var legX=PL+cW+LG;
24921          var legBlockH=legPerCol*legRowH;
24922          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24923          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24924          for(var lk=0;lk<legShown;lk++){
24925            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24926            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24927            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;">';
24928            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24929            s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24930            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>';
24931            s+='</g>';
24932          }
24933          if(legTrunc){
24934            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24935            s+='<g data-more="1" style="cursor:pointer;">';
24936            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24937            s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24938            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>';
24939            s+='</g>';
24940          }
24941          s+='</svg>';
24942          el.innerHTML=s;
24943          wireScatterLegend(el);
24944          var moreEl=el.querySelector('g[data-more]');
24945          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24946        }
24947        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24948
24949        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24950        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24951        // the old vertical column layout on wide containers.
24952        function renderSemanticInEl(el,key,sh){
24953          if(!el||!SEM_D||!SEM_D.length)return;
24954          var n2=SEM_D.length||1;
24955          var LW=112,SH=sh||Math.max(180,n2*28+26);
24956          var svgW=Math.max(320,el.offsetWidth||480);
24957          var BW=Math.max(120,svgW-LW-80);
24958          var topPad=4,botPad=14;
24959          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24960          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24961          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24962          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">';
24963          SEM_D.forEach(function(d,i){
24964            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24965            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>';
24966            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"/>';
24967            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>';
24968          });
24969          s+='</svg>';
24970          el.innerHTML=s;
24971        }
24972        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24973        var semSel=document.getElementById('r-semantic-metric');
24974        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24975        var semExpand=document.getElementById('r-semantic-expand');
24976        if(semExpand){
24977          semExpand.addEventListener('click',function(){
24978            var key=semSel?semSel.value:'functions';
24979            var n=SEM_D.length||1;
24980            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24981            var modalH=Math.min(Math.max(360,n*38+60),maxH);
24982            var overlay=document.createElement('div');
24983            overlay.className='r-chart-modal-overlay';
24984            var optHtml=
24985              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24986              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24987              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24988              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24989              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24990            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>';
24991            document.body.appendChild(overlay);
24992            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24993            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24994            var modalEl=document.getElementById('r-sem-modal-chart');
24995            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24996            var modalSel=document.getElementById('r-sem-modal-metric');
24997            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24998          });
24999        }
25000
25001        // ── Expand buttons: re-render charts at large size inside modal ──────────
25002        (function(){
25003          function makeExpandModal(title,mH,subtitle,ctrlHtml){
25004            var overlay=document.createElement('div');
25005            overlay.className='r-chart-modal-overlay';
25006            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25007            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25008            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>';
25009            document.body.appendChild(overlay);
25010            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25011            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25012            return overlay.querySelector('.r-expand-modal-chart');
25013          }
25014          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25015          var compExpandBtn=document.getElementById('r-composition-expand');
25016          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25017            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25018            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25019            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25020              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25021            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25022            if(wrap){
25023              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25024              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25025                btn.addEventListener('click',function(){
25026                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25027                  btn.classList.add('active');
25028                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25029                });
25030              });
25031            }
25032          });}
25033          var scatExpandBtn=document.getElementById('r-scatter-expand');
25034          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25035            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25036            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25037          });}
25038          var densExpandBtn=document.getElementById('r-density-expand');
25039          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25040            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25041            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25042            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25043          });}
25044          var avgExpandBtn=document.getElementById('r-avglines-expand');
25045          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25046            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25047            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25048            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25049          });}
25050          var subExpandBtn=document.getElementById('r-submodule-expand');
25051          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25052            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25053            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25054            var metCtrl=
25055              '<select class="r-chart-select" id="r-sub-modal-metric">'
25056              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25057              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25058              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25059              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25060              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25061              +'</select>';
25062            var sortCtrl=
25063              '<select class="r-chart-select" id="r-sub-modal-sort">'
25064              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25065              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25066              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25067              +'</select>';
25068            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25069            if(wrap){
25070              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25071              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25072              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25073              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25074              if(mSub)mSub.addEventListener('change',reRenderSub);
25075              if(mSort)mSort.addEventListener('change',reRenderSub);
25076            }
25077          });}
25078        })();
25079
25080        // ── Comment Density: comments / (code + comments) per language ───────────
25081        function renderDensityInEl(el,shOvr){
25082          if(!el||!LANG_D||!LANG_D.length)return;
25083          var n=LANG_D.length||1;
25084          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25085          var svgW=Math.max(320,el.offsetWidth||480);
25086          var BW=Math.max(120,svgW-LW-80);
25087          var topPad=4,botPad=26;
25088          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25089          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25090          var densities=LANG_D.map(function(d){
25091            var sig=(d.code||0)+(d.comments||0);
25092            return sig>0?(d.comments||0)/sig:0;
25093          });
25094          var maxDen=Math.max.apply(null,densities)||1;
25095          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">';
25096          LANG_D.forEach(function(d,i){
25097            var den=densities[i],bw=den/maxDen*BW;
25098            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25099            var pct=Math.round(den*100);
25100            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>';
25101            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"/>';
25102            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25103            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>';
25104          });
25105          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>';
25106          s+='</svg>';
25107          el.innerHTML=s;
25108        }
25109        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25110        renderDensity();
25111
25112        // ── Avg Lines per File: code / files per language ─────────────────────
25113        function renderAvgLinesInEl(el,shOvr){
25114          if(!el||!LANG_D||!LANG_D.length)return;
25115          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25116          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25117          var n=data.length||1;
25118          var LW=112,SH=shOvr||Math.max(180,n*28+26);
25119          var svgW=Math.max(320,el.offsetWidth||480);
25120          var BW=Math.max(120,svgW-LW-80);
25121          var topPad=4,botPad=26;
25122          var rowTotal=Math.floor((SH-topPad-botPad)/n);
25123          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25124          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25125          var maxAvg=Math.max.apply(null,avgs)||1;
25126          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">';
25127          data.forEach(function(d,i){
25128            var avg=avgs[i],bw=avg/maxAvg*BW;
25129            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25130            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>';
25131            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"/>';
25132            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25133            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>';
25134          });
25135          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>';
25136          s+='</svg>';
25137          el.innerHTML=s;
25138        }
25139        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25140        renderAvgLines();
25141
25142        // ── Repository Overview: overall row + per-submodule rows ────────────
25143        function renderSubmoduleInEl(el,key,sort,shOvr){
25144          if(!el)return;
25145          var overall={
25146            name:'Overall',
25147            code:{{ code_lines }},
25148            comment:{{ comment_lines }},
25149            blank:{{ blank_lines }},
25150            physical:{{ physical_lines }},
25151            files:{{ files_analyzed }},
25152            isOverall:true
25153          };
25154          var subs=SUB_D.slice();
25155          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25156          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25157          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25158          var data=[overall].concat(subs);
25159          var sepH=subs.length>0?14:0;
25160          var naturalH=data.length*32+sepH+16;
25161          var SH=shOvr||Math.max(100,naturalH);
25162          var svgW=Math.max(320,el.offsetWidth||480);
25163          var LW=116,BW=Math.max(200,svgW-LW-54);
25164          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25165          var OVERALL_COL='#6b7280';
25166          var topPad=4,botPad=8;
25167          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25168          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25169          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">';
25170          var yOff=topPad;
25171          data.forEach(function(d,i){
25172            var v=d[key]||0,bw=v/maxV*BW;
25173            var y=yOff+Math.floor((rowSlot-bH)/2);
25174            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25175            var label=d.name||d.path||'?';
25176            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>';
25177            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25178            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25179            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>';
25180            yOff+=rowSlot;
25181            if(d.isOverall&&subs.length>0){
25182              yOff+=sepH;
25183            }
25184          });
25185          s+='</svg>';
25186          el.innerHTML=s;
25187        }
25188        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25189        var subSel=document.getElementById('r-sub-metric');
25190        var sortSel=document.getElementById('r-sub-sort');
25191        renderSubmodule('code','desc');
25192        if(subSel){
25193          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25194          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25195        }
25196
25197        // Equalise heights within each chart row: if one chart in a grid row is taller
25198        // than its neighbour, re-render the shorter one at the taller height so bars fill
25199        // the available vertical space instead of leaving a gap.
25200        function syncRowHeights(){
25201          var avgEl=document.getElementById('r-avglines-chart');
25202          var subEl=document.getElementById('r-submodule-chart');
25203          if(avgEl&&subEl){
25204            var avgSvg=avgEl.querySelector('svg');
25205            var subSvg=subEl.querySelector('svg');
25206            if(avgSvg&&subSvg){
25207              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25208              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25209              var key=subSel?subSel.value||'code':'code';
25210              var sort=sortSel?sortSel.value:'desc';
25211              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25212              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25213            }
25214          }
25215          var semEl=document.getElementById('r-semantic-chart');
25216          var denEl=document.getElementById('r-density-chart');
25217          if(semEl&&denEl){
25218            var semSvg=semEl.querySelector('svg');
25219            var denSvg=denEl.querySelector('svg');
25220            if(semSvg&&denSvg){
25221              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25222              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25223              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25224              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25225            }
25226          }
25227        }
25228        syncRowHeights();
25229
25230        // Re-render all SVG charts when the window is resized so bars fill the card.
25231        var _rResizeTimer;
25232        window.addEventListener('resize',function(){
25233          clearTimeout(_rResizeTimer);
25234          _rResizeTimer=setTimeout(function(){
25235            var rcompBtn=document.querySelector('[data-rcomp].active');
25236            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25237            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25238            if(semSel)renderSemantic(semSel.value||'functions');
25239            renderDensity();
25240            renderAvgLines();
25241            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25242            syncRowHeights();
25243          },120);
25244        });
25245      })();
25246
25247      (function randomizeWatermarks() {
25248        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25249        if (!wms.length) return;
25250        var placed = [];
25251        function tooClose(top, left) {
25252          for (var i = 0; i < placed.length; i++) {
25253            var dt = Math.abs(placed[i][0] - top);
25254            var dl = Math.abs(placed[i][1] - left);
25255            if (dt < 20 && dl < 18) return true;
25256          }
25257          return false;
25258        }
25259        function pick(leftBand) {
25260          for (var attempt = 0; attempt < 50; attempt++) {
25261            var top = Math.random() * 85 + 5;
25262            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25263            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25264          }
25265          var top = Math.random() * 85 + 5;
25266          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25267          placed.push([top, left]);
25268          return [top, left];
25269        }
25270        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25271        var half = Math.floor(wms.length / 2);
25272        wms.forEach(function (img, i) {
25273          var pos = pick(i < half);
25274          var size = Math.floor(Math.random() * 100 + 160);
25275          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25276          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25277          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;
25278        });
25279      })();
25280
25281      (function spawnCodeParticles() {
25282        var container = document.getElementById('code-particles');
25283        if (!container) return;
25284        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'];
25285        for (var i = 0; i < 38; i++) {
25286          (function(idx) {
25287            var el = document.createElement('span');
25288            el.className = 'code-particle';
25289            el.textContent = snippets[idx % snippets.length];
25290            var left = Math.random() * 94 + 2;
25291            var top = Math.random() * 88 + 6;
25292            var dur = (Math.random() * 10 + 9).toFixed(1);
25293            var delay = (Math.random() * 18).toFixed(1);
25294            var rot = (Math.random() * 26 - 13).toFixed(1);
25295            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25296            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';
25297            container.appendChild(el);
25298          })(i);
25299        }
25300      })();
25301
25302      {% if pdf_generating %}
25303      // Poll for PDF readiness and swap the disabled button to a live link once done.
25304      (function() {
25305        var openBtn = document.getElementById('pdf-open-btn');
25306        var dlBtn = document.getElementById('pdf-download-btn');
25307        function checkPdf() {
25308          fetch('/api/runs/{{ run_id }}/pdf-status')
25309            .then(function(r) { return r.json(); })
25310            .then(function(d) {
25311              if (d.ready) {
25312                if (openBtn) {
25313                  var a = document.createElement('a');
25314                  a.className = 'button';
25315                  a.id = 'pdf-open-btn';
25316                  a.href = '/runs/pdf/{{ run_id }}';
25317                  a.target = '_blank';
25318                  a.rel = 'noopener';
25319                  a.textContent = 'Open PDF';
25320                  openBtn.replaceWith(a);
25321                }
25322                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25323              } else {
25324                setTimeout(checkPdf, 3000);
25325              }
25326            })
25327            .catch(function() { setTimeout(checkPdf, 5000); });
25328        }
25329        setTimeout(checkPdf, 3000);
25330      })();
25331      {% endif %}
25332
25333    })();
25334  </script>
25335  <script nonce="{{ csp_nonce }}">
25336  (function(){
25337    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'}];
25338    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);});}
25339    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25340    function init(){
25341      var btn=document.getElementById('settings-btn');if(!btn)return;
25342      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25343      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>';
25344      document.body.appendChild(m);
25345      var g=document.getElementById('scheme-grid');
25346      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);});
25347      var cl=document.getElementById('settings-close');
25348      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);});})();
25349      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');});
25350      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25351      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25352    }
25353    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25354  }());
25355  </script>
25356  <footer class="site-footer">
25357    local code analysis - metrics, history and reports
25358    &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>
25359    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25360    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25361    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25362    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25363  </footer>
25364  {% if confluence_configured %}
25365  <script nonce="{{ csp_nonce }}">
25366  (function() {
25367    var postBtn = document.getElementById('postConfluenceBtn');
25368    var copyBtn = document.getElementById('copyWikiBtn');
25369    var modal   = document.getElementById('confluenceModal');
25370    if (!postBtn || !modal) return;
25371
25372    postBtn.addEventListener('click', function() {
25373      document.getElementById('confStatus').style.display = 'none';
25374      modal.style.display = 'flex';
25375    });
25376    document.getElementById('confCancelBtn').addEventListener('click', function() {
25377      modal.style.display = 'none';
25378    });
25379    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25380
25381    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25382      var btn = this;
25383      btn.disabled = true;
25384      var status = document.getElementById('confStatus');
25385      status.style.display = 'block';
25386      status.style.background = '#dbeafe';
25387      status.style.color = '#1e40af';
25388      status.textContent = 'Posting to Confluence\u2026';
25389      var resp = await fetch('/api/confluence/post', {
25390        method: 'POST',
25391        headers: { 'Content-Type': 'application/json' },
25392        body: JSON.stringify({
25393          run_id: '{{ run_id }}',
25394          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25395          report_url: document.getElementById('confReportUrl').value.trim() || null
25396        })
25397      });
25398      var data = await resp.json();
25399      if (data.ok) {
25400        status.style.background = '#dcfce7'; status.style.color = '#166534';
25401        status.textContent = 'Posted! Page ID: ' + data.page_id;
25402      } else {
25403        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25404        status.textContent = 'Error: ' + (data.error || 'Unknown error');
25405      }
25406      btn.disabled = false;
25407    });
25408
25409    if (copyBtn) {
25410      copyBtn.addEventListener('click', async function() {
25411        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25412        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25413        var text = await resp.text();
25414        try {
25415          await navigator.clipboard.writeText(text);
25416          var orig = copyBtn.textContent;
25417          copyBtn.textContent = 'Copied!';
25418          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25419        } catch(e) {
25420          alert('Clipboard write failed \u2014 check browser permissions.');
25421        }
25422      });
25423    }
25424  })();
25425  </script>
25426  {% endif %}
25427  <script nonce="{{ csp_nonce }}">
25428  (function() {
25429    var deleteBtn = document.getElementById('delete-run-btn');
25430    var modal     = document.getElementById('delete-run-modal');
25431    var cancelBtn = document.getElementById('delete-run-cancel');
25432    var confirmBtn= document.getElementById('delete-run-confirm');
25433    if (!deleteBtn || !modal) return;
25434    deleteBtn.addEventListener('click', function() {
25435      document.getElementById('delete-run-status').style.display = 'none';
25436      modal.style.display = 'flex';
25437    });
25438    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25439    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25440    confirmBtn.addEventListener('click', async function() {
25441      confirmBtn.disabled = true;
25442      cancelBtn.disabled = true;
25443      var status = document.getElementById('delete-run-status');
25444      status.style.display = 'block';
25445      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25446      status.textContent = 'Deleting\u2026';
25447      try {
25448        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25449        if (resp.status === 204 || resp.ok) {
25450          status.style.background = '#dcfce7'; status.style.color = '#166534';
25451          status.textContent = 'Deleted. Redirecting\u2026';
25452          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25453        } else {
25454          var d = await resp.json().catch(function(){return {};});
25455          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25456          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25457          confirmBtn.disabled = false;
25458          cancelBtn.disabled = false;
25459        }
25460      } catch (e) {
25461        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25462        status.textContent = 'Network error: ' + String(e);
25463        confirmBtn.disabled = false;
25464        cancelBtn.disabled = false;
25465      }
25466    });
25467  })();
25468  </script>
25469  <script nonce="{{ csp_nonce }}">(function(){
25470    var bundleBtn = document.getElementById('download-bundle-btn');
25471    if (bundleBtn) {
25472      bundleBtn.addEventListener('click', function() {
25473        bundleBtn.disabled = true;
25474        var orig = bundleBtn.textContent;
25475        bundleBtn.textContent = 'Preparing\u2026';
25476        fetch('/api/runs/{{ run_id }}/bundle')
25477          .then(function(r) {
25478            if (!r.ok) throw new Error('HTTP ' + r.status);
25479            return r.blob();
25480          })
25481          .then(function(blob) {
25482            var url = URL.createObjectURL(blob);
25483            var a = document.createElement('a');
25484            a.href = url;
25485            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25486            document.body.appendChild(a);
25487            a.click();
25488            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25489            bundleBtn.disabled = false;
25490            bundleBtn.textContent = orig;
25491          })
25492          .catch(function(e) {
25493            bundleBtn.disabled = false;
25494            bundleBtn.textContent = orig;
25495            alert('Bundle download failed: ' + String(e));
25496          });
25497      });
25498    }
25499  })();</script>
25500  <script nonce="{{ csp_nonce }}">(function(){
25501    var dot=document.getElementById('status-dot');
25502    var pingEl=document.getElementById('server-ping-ms');
25503    var tipEl=document.getElementById('server-tip-ping');
25504    var fm=document.getElementById('footer-mode');
25505    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)';}}
25506    function doPing(){
25507      var t0=performance.now();
25508      fetch('/healthz',{cache:'no-store'})
25509        .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);})
25510        .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)';}});
25511    }
25512    doPing();
25513    setInterval(doPing,5000);
25514    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');}
25515  })();</script>
25516  <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>
25517  {% if let Some(banner) = report_header_footer %}
25518  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25519  {% endif %}
25520</body>
25521</html>
25522"##,
25523    ext = "html"
25524)]
25525// Template structs need many bool fields to pass Askama rendering flags.
25526#[allow(clippy::struct_excessive_bools)]
25527struct ResultTemplate {
25528    version: &'static str,
25529    report_title: String,
25530    project_path: String,
25531    output_dir: String,
25532    run_id: String,
25533    files_analyzed: u64,
25534    files_skipped: u64,
25535    physical_lines: u64,
25536    code_lines: u64,
25537    comment_lines: u64,
25538    blank_lines: u64,
25539    mixed_lines: u64,
25540    functions: u64,
25541    classes: u64,
25542    variables: u64,
25543    imports: u64,
25544    html_url: Option<String>,
25545    pdf_url: Option<String>,
25546    json_url: Option<String>,
25547    html_download_url: Option<String>,
25548    pdf_download_url: Option<String>,
25549    json_download_url: Option<String>,
25550    html_path: Option<String>,
25551    json_path: Option<String>,
25552    prev_run_id: Option<String>,
25553    prev_run_timestamp: Option<String>,
25554    prev_run_code_lines: Option<u64>,
25555    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
25556    prev_fa_str: String,
25557    prev_fs_str: String,
25558    prev_pl_str: String,
25559    prev_cl_str: String,
25560    prev_cml_str: String,
25561    prev_bl_str: String,
25562    // Signed change column for main metrics
25563    delta_fa_str: String,
25564    delta_fa_class: String,
25565    delta_fs_str: String,
25566    delta_fs_class: String,
25567    delta_pl_str: String,
25568    delta_pl_class: String,
25569    delta_cl_str: String,
25570    delta_cl_class: String,
25571    delta_cml_str: String,
25572    delta_cml_class: String,
25573    delta_bl_str: String,
25574    delta_bl_class: String,
25575    // delta vs previous scan
25576    delta_lines_added: Option<i64>,
25577    delta_lines_removed: Option<i64>,
25578    delta_lines_net_str: String,
25579    delta_lines_net_class: String,
25580    delta_files_added: Option<usize>,
25581    delta_files_removed: Option<usize>,
25582    delta_files_modified: Option<usize>,
25583    delta_files_unchanged: Option<usize>,
25584    delta_unmodified_lines: Option<u64>,
25585    // git context
25586    git_branch: Option<String>,
25587    git_branch_url: Option<String>,
25588    git_commit: Option<String>,
25589    git_commit_long: Option<String>,
25590    git_author: Option<String>,
25591    git_commit_url: Option<String>,
25592    // scan metadata for hero section
25593    scan_performed_by: String,
25594    scan_time_display: String,
25595    scan_time_utc_ms: i64,
25596    os_display: String,
25597    test_count: u64,
25598    // reserve "pad" card, revealed by JS only when the visible card count is odd
25599    test_assertion_count: u64,
25600    // history
25601    prev_scan_count: usize,
25602    current_scan_number: usize,
25603    // submodule breakdown (empty when not requested)
25604    submodule_rows: Vec<SubmoduleRow>,
25605    scan_config_url: String,
25606    lang_chart_json: String,
25607    // Askama reads these via proc-macro expansion; clippy can't trace through it.
25608    #[allow(dead_code)]
25609    scatter_chart_json: String,
25610    #[allow(dead_code)]
25611    semantic_chart_json: String,
25612    #[allow(dead_code)]
25613    submodule_chart_json: String,
25614    #[allow(dead_code)]
25615    has_submodule_data: bool,
25616    #[allow(dead_code)]
25617    has_semantic_data: bool,
25618    pdf_generating: bool,
25619    csp_nonce: String,
25620    /// Whether Confluence integration is configured — shows Post button when true.
25621    confluence_configured: bool,
25622    server_mode: bool,
25623    /// Header/footer identification banner, mirrored from the HTML/PDF report.
25624    report_header_footer: Option<String>,
25625    run_id_short: String,
25626    /// True when rendering a static offline file (index.html); hides server-only actions.
25627    #[allow(dead_code)]
25628    is_offline: bool,
25629    /// Total cyclomatic complexity score across all analyzed files.
25630    cyclomatic_complexity: u64,
25631    /// Logical SLOC (statement count) when available; None for unsupported languages.
25632    lsloc: Option<u64>,
25633    /// Unique Lines of Code across all analyzed files.
25634    uloc: u64,
25635    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
25636    dryness_pct_str: String,
25637    /// Number of duplicate file groups detected.
25638    duplicate_group_count: usize,
25639    /// Whether a COCOMO estimate is available to display.
25640    has_cocomo: bool,
25641    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
25642    cocomo_effort_str: String,
25643    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
25644    cocomo_duration_str: String,
25645    /// Pre-formatted average team size, e.g. "2.32".
25646    cocomo_staff_str: String,
25647    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
25648    cocomo_ksloc_str: String,
25649    /// COCOMO mode label shown in the card (e.g. "Organic").
25650    cocomo_mode_label: String,
25651    /// Tooltip text explaining the selected COCOMO mode.
25652    cocomo_mode_tooltip: String,
25653    /// Per-file complexity alert threshold. 0 = off (no highlighting).
25654    complexity_alert: u32,
25655    /// Whether any file has coverage data attached.
25656    has_coverage_data: bool,
25657    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
25658    cov_line_pct: String,
25659    /// Overall function coverage percentage string — empty if no data.
25660    cov_fn_pct: String,
25661    /// Overall branch coverage percentage string — empty if no branch data.
25662    cov_branch_pct: String,
25663    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
25664    cov_lines_summary: String,
25665}
25666
25667#[derive(Template)]
25668#[template(
25669    source = r##"
25670<!doctype html>
25671<html lang="en">
25672<head>
25673  <meta charset="utf-8">
25674  <meta name="viewport" content="width=device-width, initial-scale=1">
25675  <title>OxideSLOC | Analyzing…</title>
25676  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25677  <style nonce="{{ csp_nonce }}">
25678    :root {
25679      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25680      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25681      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25682      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25683    }
25684    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25685    *{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;}
25686    .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);}
25687    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25688    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25689    .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));}
25690    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25691    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25692    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25693    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25694    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25695    @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; } }
25696    .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;}
25697    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25698    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25699    .page-body{padding:32px 24px 36px;}
25700    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25701    .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;}
25702    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25703    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25704    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25705    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25706    .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;}
25707    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25708    .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;}
25709    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25710    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25711    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25712    .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;}
25713    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25714    .hidden{display:none!important;}
25715    .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;}
25716    .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;}
25717    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25718    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25719    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25720    .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);}
25721    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25722    .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;}
25723    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25724    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25725    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25726    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25727    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25728    .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;}
25729    @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));}}
25730    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25731    .site-footer a{color:var(--muted);}
25732    .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;}
25733    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25734    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25735    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25736  </style>
25737</head>
25738<body>
25739  <div class="background-watermarks" aria-hidden="true">
25740    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25741    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25742    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25743    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25744    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25745    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25746  </div>
25747  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25748  <nav class="top-nav">
25749    <div class="top-nav-inner">
25750      <a href="/" class="brand">
25751        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25752        <div class="brand-copy">
25753          <h1 class="brand-title">OxideSLOC</h1>
25754          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25755        </div>
25756      </a>
25757      <div class="nav-right">
25758        <a class="nav-pill" href="/">Home</a>
25759        <div class="nav-dropdown">
25760          <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>
25761          <div class="nav-dropdown-menu">
25762            <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>
25763          </div>
25764        </div>
25765        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25766        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25767        <div class="nav-dropdown">
25768          <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>
25769          <div class="nav-dropdown-menu">
25770            <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>
25771          </div>
25772        </div>
25773        <div class="server-status-wrap" id="server-status-wrap">
25774          <div class="nav-pill server-online-pill" id="server-status-pill">
25775            <span class="status-dot" id="status-dot"></span>
25776            <span id="server-status-label">Server</span>
25777            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25778          </div>
25779          <div class="server-status-tip">
25780            OxideSLOC is running — accessible on your network.
25781            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25782          </div>
25783        </div>
25784        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25785          <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>
25786        </button>
25787        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25788          <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>
25789          <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>
25790        </button>
25791      </div>
25792    </div>
25793  </nav>
25794  <div class="page-body">
25795    <div class="wait-panel">
25796      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25797      <h2 class="wait-title">Analyzing your project…</h2>
25798      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25799      <div class="path-block">{{ project_path }}</div>
25800      <div class="metrics-row">
25801        <div class="metric-card">
25802          <div class="metric-label">Elapsed</div>
25803          <div class="metric-value" id="elapsed">0s</div>
25804        </div>
25805        <div class="metric-card">
25806          <div class="metric-label">Phase</div>
25807          <div class="metric-value" id="phase">Starting</div>
25808        </div>
25809        <div class="metric-card hidden" id="files-card">
25810          <div class="metric-label">Files</div>
25811          <div class="metric-value" id="files-progress">0</div>
25812        </div>
25813      </div>
25814      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25815      <div class="warn-slow hidden" id="warn-slow">
25816        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.
25817      </div>
25818      <div class="err-panel hidden" id="err-panel">
25819        <strong>Analysis failed</strong>
25820        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25821      </div>
25822      <div class="actions hidden" id="actions">
25823        <a href="/scan" class="btn-primary">Try Again</a>
25824        <a href="/view-reports" class="btn-outline">View Reports</a>
25825      </div>
25826    </div>
25827  </div>
25828  <script nonce="{{ csp_nonce }}">
25829    (function() {
25830      var WAIT_ID = {{ wait_id_json|safe }};
25831      var startTime = Date.now();
25832      var pollInterval = 1500;
25833      var retries = 0;
25834      var maxRetries = 5;
25835      var warnShown = false;
25836
25837      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();}
25838
25839      function elapsed() {
25840        return Math.floor((Date.now() - startTime) / 1000);
25841      }
25842
25843      function updateElapsed() {
25844        var s = elapsed();
25845        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25846      }
25847
25848      function setPhase(txt) {
25849        document.getElementById('phase').textContent = txt;
25850      }
25851
25852      var elapsedTimer = setInterval(updateElapsed, 1000);
25853
25854      function poll() {
25855        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25856          .then(function(r) {
25857            if (!r.ok) throw new Error('HTTP ' + r.status);
25858            return r.json();
25859          })
25860          .then(function(data) {
25861            retries = 0;
25862            if (data.state === 'complete') {
25863              clearInterval(elapsedTimer);
25864              setPhase('Done');
25865              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25866            } else if (data.state === 'failed') {
25867              clearInterval(elapsedTimer);
25868              setPhase('Failed');
25869              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25870              document.getElementById('err-panel').classList.remove('hidden');
25871              document.getElementById('actions').classList.remove('hidden');
25872            } else {
25873              // still running
25874              var s = elapsed();
25875              if (s > 90 && !warnShown) {
25876                warnShown = true;
25877                document.getElementById('warn-slow').classList.remove('hidden');
25878              }
25879              setPhase(data.phase || 'Running');
25880              var fd = data.files_done || 0, ft = data.files_total || 0;
25881              if (ft > 0) {
25882                var card = document.getElementById('files-card');
25883                if (card) card.classList.remove('hidden');
25884                var fp = document.getElementById('files-progress');
25885                if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25886              }
25887              setTimeout(poll, pollInterval);
25888            }
25889          })
25890          .catch(function(err) {
25891            retries++;
25892            if (retries >= maxRetries) {
25893              clearInterval(elapsedTimer);
25894              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25895              document.getElementById('err-panel').classList.remove('hidden');
25896              document.getElementById('actions').classList.remove('hidden');
25897            } else {
25898              // exponential back-off capped at 8s
25899              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25900            }
25901          });
25902      }
25903
25904      setTimeout(poll, pollInterval);
25905
25906      // If the browser restores this page from bfcache (Back after viewing results),
25907      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25908      window.addEventListener("pageshow", function(e) {
25909        if (e.persisted) { setTimeout(poll, 200); }
25910      });
25911    })();
25912  </script>
25913  <footer class="site-footer">
25914    local code analysis - metrics, history and reports
25915    &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>
25916    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25917    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25918    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25919    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25920  </footer>
25921  <script nonce="{{ csp_nonce }}">
25922    (function(){
25923      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25924      if(s==="dark")b.classList.add("dark-theme");
25925      var tt=document.getElementById("theme-toggle");
25926      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25927    })();
25928    (function spawnCodeParticles(){
25929      var c=document.getElementById('code-particles');if(!c)return;
25930      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'];
25931      for(var i=0;i<32;i++){(function(idx){
25932        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25933        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25934        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25935        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25936        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25937        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25938        c.appendChild(el);
25939      })(i);}
25940    })();
25941    (function randomizeWatermarks(){
25942      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25943      var placed=[];
25944      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;}
25945      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];}
25946      var half=Math.floor(wms.length/2);
25947      wms.forEach(function(img,i){
25948        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25949        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25950        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25951        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25952        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25953        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25954      });
25955    })();
25956  </script>
25957  <script nonce="{{ csp_nonce }}">
25958  (function(){
25959    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'}];
25960    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);});}
25961    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25962    function init(){
25963      var btn=document.getElementById('settings-btn');if(!btn)return;
25964      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25965      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>';
25966      document.body.appendChild(m);
25967      var g=document.getElementById('scheme-grid');
25968      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);});
25969      var cl=document.getElementById('settings-close');
25970      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);});})();
25971      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');});
25972      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25973      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25974    }
25975    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25976  }());
25977  </script>
25978  <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]';
25979  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;}
25980  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>
25981</body>
25982</html>
25983"##,
25984    ext = "html"
25985)]
25986struct ScanWaitTemplate {
25987    version: &'static str,
25988    wait_id_json: String,
25989    project_path: String,
25990    csp_nonce: String,
25991}
25992
25993#[derive(Template)]
25994#[template(
25995    source = r##"
25996<!doctype html>
25997<html lang="en">
25998<head>
25999  <meta charset="utf-8">
26000  <meta name="viewport" content="width=device-width, initial-scale=1">
26001  <title>OxideSLOC | Error</title>
26002  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26003  <style nonce="{{ csp_nonce }}">
26004    :root {
26005      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26006      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26007      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26008      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26009    }
26010    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26011    *{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;}
26012    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26013    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26014    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26015    .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);}
26016    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26017    .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));}
26018    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26019    .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;}
26020    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26021    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26022    @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; } }
26023    .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;}
26024    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26025    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26026    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26027    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26028    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26029    .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;}
26030    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26031    .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);}
26032    .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;}
26033    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26034    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26035    .settings-modal-body{padding:14px 16px 16px;}
26036    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26037    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26038    .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;}
26039    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26040    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26041    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26042    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26043    .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;}
26044    .tz-select:focus{border-color:var(--oxide);}
26045    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26046    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26047    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26048    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26049    .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;}
26050    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26051    .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);}
26052    .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;}
26053    .btn-secondary:hover{background:var(--line);}
26054    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26055    .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;}
26056    .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;}
26057    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26058    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26059    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26060    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26061    .bug-report-panel.open{display:flex;}
26062    .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;}
26063    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26064    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26065    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26066    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26067    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26068    .br-network-badge.online .br-net-dot{background:#2a6846;}
26069    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26070    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26071    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26072    .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;}
26073    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26074    .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;}
26075    .btn-sm:hover{background:var(--line);}
26076    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26077    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26078    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26079    .bug-report-hint a:hover{text-decoration:underline;}
26080    .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;}
26081    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26082    .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;}
26083    .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;}
26084    .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;}
26085    @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));}}
26086    .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;}
26087  </style>
26088</head>
26089<body>
26090  <div class="background-watermarks" aria-hidden="true">
26091    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26092    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26093    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26094    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26095    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26096    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26097  </div>
26098  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26099  <div class="top-nav">
26100    <div class="top-nav-inner">
26101      <a class="brand" href="/">
26102        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26103        <div class="brand-copy">
26104          <div class="brand-title">OxideSLOC</div>
26105          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26106        </div>
26107      </a>
26108      <div class="nav-right">
26109        <a class="nav-pill" href="/">Home</a>
26110        <div class="nav-dropdown">
26111          <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>
26112          <div class="nav-dropdown-menu">
26113            <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>
26114          </div>
26115        </div>
26116        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26117        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26118        <div class="nav-dropdown">
26119          <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>
26120          <div class="nav-dropdown-menu">
26121            <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>
26122          </div>
26123        </div>
26124        <div class="server-status-wrap" id="server-status-wrap">
26125          <div class="nav-pill server-online-pill" id="server-status-pill">
26126            <span class="status-dot" id="status-dot"></span>
26127            <span id="server-status-label">Server</span>
26128            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26129          </div>
26130          <div class="server-status-tip">
26131            OxideSLOC is running — accessible on your network.
26132            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26133          </div>
26134        </div>
26135        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26136          <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>
26137        </button>
26138        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26139          <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>
26140          <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>
26141        </button>
26142      </div>
26143    </div>
26144  </div>
26145
26146  <div class="page">
26147    <div class="panel">
26148      <h1>Error</h1>
26149      <div class="error-box" id="error-msg-text">{{ message }}</div>
26150      <div id="br-meta" hidden
26151        data-version="{{ version }}"
26152        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26153        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26154      <div class="actions">
26155        <a class="btn-primary" href="/scan">Back to setup</a>
26156        {% if let Some(report_url) = last_report_url %}
26157        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26158        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26159        {% else %}
26160        <a class="btn-secondary" href="/view-reports">View Reports</a>
26161        {% endif %}
26162      </div>
26163      <div class="bug-report-section" id="bug-report-section">
26164        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26165          <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>
26166          Generate Bug Report
26167          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26168        </button>
26169        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26170          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
26171          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
26172          <div class="bug-report-btns">
26173            <button type="button" class="btn-sm" id="bug-report-copy">
26174              <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>
26175              Copy to clipboard
26176            </button>
26177            <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;">
26178              <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>
26179              Open GitHub Issue
26180            </a>
26181            <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26182              <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>
26183              Save as file
26184            </button>
26185          </div>
26186          <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>
26187          <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>
26188        </div>
26189      </div>
26190    </div>
26191  </div>
26192  <footer class="site-footer">
26193    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26194    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26195    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26196    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26197    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26198  </footer>
26199  <script nonce="{{ csp_nonce }}">(function(){
26200    var meta=document.getElementById('br-meta');
26201    var pre=document.getElementById('bug-report-pre');
26202    var copyBtn=document.getElementById('bug-report-copy');
26203    var trigger=document.getElementById('bug-report-trigger');
26204    var panel=document.getElementById('bug-report-panel');
26205    var networkBadge=document.getElementById('br-network-badge');
26206    var networkLabel=document.getElementById('br-network-label');
26207    var ghLink=document.getElementById('bug-report-github-link');
26208    var saveBtn=document.getElementById('bug-report-save');
26209    var hintOnline=document.getElementById('br-hint-online');
26210    var hintOffline=document.getElementById('br-hint-offline');
26211    if(!meta||!pre)return;
26212    var ver=meta.getAttribute('data-version')||'';
26213    var runId=meta.getAttribute('data-run-id')||'';
26214    var code=meta.getAttribute('data-error-code')||'';
26215    var msgEl=document.getElementById('error-msg-text');
26216    var msg=msgEl?msgEl.textContent.trim():'';
26217    function getBrowser(){
26218      var ua=navigator.userAgent;
26219      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26220      if(!m)return 'Unknown browser';
26221      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26222      return n+' '+m[2];
26223    }
26224    var lines=['oxide-sloc Bug Report','==============================',''];
26225    lines.push('App version:  v'+ver);
26226    if(code)lines.push('HTTP status:  '+code);
26227    if(runId)lines.push('Run ID:       '+runId);
26228    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
26229    lines.push('Timestamp:    '+new Date().toISOString());
26230    lines.push('Browser:      '+getBrowser());
26231    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
26232    lines.push('');
26233    lines.push('Error message:');
26234    lines.push(msg);
26235    lines.push('');
26236    lines.push('Steps to reproduce:');
26237    lines.push('  1. ');
26238    lines.push('');
26239    lines.push('Expected behavior:');
26240    lines.push('  ');
26241    pre.textContent=lines.join('\n');
26242    function applyNetwork(online){
26243      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26244      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26245      if(ghLink){
26246        if(online){
26247          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26248          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26249        }
26250        ghLink.style.display=online?'inline-flex':'none';
26251      }
26252      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26253      if(hintOnline)hintOnline.style.display=online?'block':'none';
26254      if(hintOffline)hintOffline.style.display=online?'none':'block';
26255    }
26256    applyNetwork(navigator.onLine);
26257    var probed=false;
26258    function probeNetwork(){
26259      if(probed)return;probed=true;
26260      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26261      var probeIdx=0;
26262      function tryNext(){
26263        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26264        var u=probeUrls[probeIdx++];
26265        var c2=new AbortController();
26266        var t2=setTimeout(function(){c2.abort();},4000);
26267        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26268          .then(function(){clearTimeout(t2);applyNetwork(true);})
26269          .catch(function(){clearTimeout(t2);tryNext();});
26270      }
26271      tryNext();
26272    }
26273    if(trigger&&panel){
26274      trigger.addEventListener('click',function(){
26275        var open=panel.classList.toggle('open');
26276        trigger.classList.toggle('open',open);
26277        trigger.setAttribute('aria-expanded',open?'true':'false');
26278        if(open)probeNetwork();
26279      });
26280    }
26281    if(copyBtn){
26282      copyBtn.addEventListener('click',function(){
26283        var txt=pre.textContent;
26284        if(navigator.clipboard&&navigator.clipboard.writeText){
26285          navigator.clipboard.writeText(txt).then(function(){
26286            copyBtn.textContent='\u2713 Copied!';
26287            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);
26288          });
26289        }else{
26290          var ta=document.createElement('textarea');
26291          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26292          document.body.appendChild(ta);ta.select();
26293          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26294          document.body.removeChild(ta);
26295        }
26296      });
26297    }
26298    if(saveBtn){
26299      saveBtn.addEventListener('click',function(){
26300        var txt=pre.textContent;
26301        var blob=new Blob([txt],{type:'text/plain'});
26302        var url=URL.createObjectURL(blob);
26303        var a=document.createElement('a');
26304        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26305        document.body.appendChild(a);a.click();
26306        document.body.removeChild(a);URL.revokeObjectURL(url);
26307      });
26308    }
26309  })();</script>
26310  <script nonce="{{ csp_nonce }}">
26311    (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");});})();
26312    (function spawnCodeParticles() {
26313      var container = document.getElementById('code-particles');
26314      if (!container) return;
26315      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'];
26316      for (var i = 0; i < 38; i++) {
26317        (function(idx) {
26318          var el = document.createElement('span');
26319          el.className = 'code-particle';
26320          el.textContent = snippets[idx % snippets.length];
26321          var left = Math.random() * 94 + 2;
26322          var top = Math.random() * 88 + 6;
26323          var dur = (Math.random() * 10 + 9).toFixed(1);
26324          var delay = (Math.random() * 18).toFixed(1);
26325          var rot = (Math.random() * 26 - 13).toFixed(1);
26326          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26327          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';
26328          container.appendChild(el);
26329        })(i);
26330      }
26331    })();
26332    (function randomizeWatermarks() {
26333      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26334      var placed = [];
26335      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; }
26336      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]; }
26337      var half = Math.floor(wms.length/2);
26338      wms.forEach(function(img, i) {
26339        var pos = pick(i < half);
26340        var w = Math.floor(Math.random()*60+80);
26341        var rot = (Math.random()*40-20).toFixed(1);
26342        var op = (Math.random()*0.08+0.05).toFixed(2);
26343        var animDur = (Math.random()*6+5).toFixed(1);
26344        var animDelay = (Math.random()*10).toFixed(1);
26345        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';
26346      });
26347    })();
26348  </script>
26349  <script nonce="{{ csp_nonce }}">
26350  (function(){
26351    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'}];
26352    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);});}
26353    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26354    function init(){
26355      var btn=document.getElementById('settings-btn');if(!btn)return;
26356      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26357      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>';
26358      document.body.appendChild(m);
26359      var g=document.getElementById('scheme-grid');
26360      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);});
26361      var cl=document.getElementById('settings-close');
26362      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);});})();
26363      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');});
26364      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26365      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26366    }
26367    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26368  }());
26369  </script>
26370  <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]';
26371  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;}
26372  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>
26373</body>
26374</html>
26375"##,
26376    ext = "html"
26377)]
26378struct ErrorTemplate {
26379    message: String,
26380    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
26381    last_report_url: Option<String>,
26382    /// Label for the secondary action button; defaults to "View last report" when None.
26383    last_report_label: Option<String>,
26384    /// Run ID to surface in the bug report; `None` when not applicable.
26385    run_id: Option<String>,
26386    /// HTTP status code to surface in the bug report; `None` when unknown.
26387    error_code: Option<u16>,
26388    csp_nonce: String,
26389    version: &'static str,
26390}
26391
26392// ── LocateFileTemplate ────────────────────────────────────────────────────────
26393
26394#[derive(Template)]
26395#[template(
26396    source = r##"
26397<!doctype html>
26398<html lang="en">
26399<head>
26400  <meta charset="utf-8">
26401  <meta name="viewport" content="width=device-width, initial-scale=1">
26402  <title>OxideSLOC | Locate Report</title>
26403  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26404  <style nonce="{{ csp_nonce }}">
26405    :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);}
26406    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26407    *{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;}
26408    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26409    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26410    .top-nav{position:sticky;top:0;z-index:30;background:linear-gradient(180deg,var(--nav),var(--nav-2));border-bottom:1px solid rgba(255,255,255,0.12);box-shadow:0 4px 14px rgba(0,0,0,0.18);}
26411    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26412    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}.brand-logo{width:42px;height:46px;object-fit:contain;flex:0 0 auto;filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}
26413    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26414    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}.brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26415    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26416    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26417    @media(max-width:1150px){.nav-right{gap:4px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 8px;font-size:11px;min-height:34px;}.brand-subtitle{display:none;}.server-online-pill{width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px;}}
26418    .nav-pill,.theme-toggle{display:inline-flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;transition:background .15s ease,transform .15s ease;}
26419    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26420    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26421    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26422    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26423    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26424    .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
26425    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26426    .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
26427    .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
26428    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26429    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26430    .settings-modal-body{padding:14px 16px 16px;}
26431    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26432    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26433    .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
26434    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26435    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26436    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26437    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26438    .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
26439    .tz-select:focus{border-color:var(--oxide);}
26440    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26441    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26442    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26443    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26444    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26445    .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;}
26446    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26447    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26448    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26449    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26450    .locate-row{display:flex;gap:8px;align-items:stretch;}
26451    .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;}
26452    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26453    body.dark-theme .locate-input{background:var(--surface-2);}
26454    .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;}
26455    .warning-banner.show{display:flex;}
26456    .warning-banner svg{flex:0 0 auto;}
26457    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26458    .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;}
26459    .error-inline.show{display:flex;}
26460    .error-inline svg{flex:0 0 auto;margin-top:2px;}
26461    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26462    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26463    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26464    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26465    .err-kv-p{margin:0 0 4px;}
26466    .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;}
26467    .success-inline.show{display:flex;}
26468    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26469    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26470    .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;}
26471    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26472    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26473    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26474    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26475    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26476    .fh-row:last-child{border-bottom:none;}
26477    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26478    .fh-dir{font-weight:800;color:var(--text);}
26479    .fh-hl{color:var(--oxide);font-weight:700;}
26480    .fh-muted{color:var(--muted);}
26481    .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;}
26482    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26483    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26484    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26485    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26486    .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;}
26487    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26488    .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;}
26489    .btn-secondary:hover{background:var(--line);}
26490    .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;}
26491    .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;}
26492    .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;}
26493    @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));}}
26494    .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;}
26495    .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;}
26496    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26497  </style>
26498</head>
26499<body>
26500  <div class="background-watermarks" aria-hidden="true">
26501    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26502    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26503    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26504    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26505    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26506    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26507  </div>
26508  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26509  <div class="top-nav">
26510    <div class="top-nav-inner">
26511      <a class="brand" href="/">
26512        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26513        <div class="brand-copy">
26514          <div class="brand-title">OxideSLOC</div>
26515          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26516        </div>
26517      </a>
26518      <div class="nav-right">
26519        <a class="nav-pill" href="/">Home</a>
26520        <div class="nav-dropdown">
26521          <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>
26522          <div class="nav-dropdown-menu">
26523            <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>
26524          </div>
26525        </div>
26526        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26527        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26528        <div class="nav-dropdown">
26529          <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>
26530          <div class="nav-dropdown-menu">
26531            <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>
26532          </div>
26533        </div>
26534        <div class="server-status-wrap" id="server-status-wrap">
26535          <div class="nav-pill server-online-pill" id="server-status-pill">
26536            <span class="status-dot" id="status-dot"></span>
26537            <span id="server-status-label">Server</span>
26538            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26539          </div>
26540          <div class="server-status-tip">
26541            OxideSLOC is running &mdash; accessible on your network.
26542            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26543          </div>
26544        </div>
26545        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26546          <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>
26547        </button>
26548        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26549          <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>
26550          <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>
26551        </button>
26552      </div>
26553    </div>
26554  </div>
26555
26556  <div class="page">
26557    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26558    <div class="panel">
26559      <h1>Report File Not Found</h1>
26560      <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>
26561      <div class="field-label">Missing file</div>
26562      <div class="filename-chip">
26563        <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>
26564        {{ expected_filename }}
26565      </div>
26566      <div class="locate-section">
26567        <h2>Locate Scan Output Folder</h2>
26568        <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>
26569        <p>OxideSLOC will find the correct files inside automatically.</p>
26570        <div class="locate-row">
26571          <input type="text" id="locate-file-input"
26572                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26573                 class="locate-input" autocomplete="off" spellcheck="false">
26574          {% if !server_mode %}
26575          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
26576          {% endif %}
26577        </div>
26578        <div class="warning-banner" id="filename-warning">
26579          <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>
26580          <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>
26581        </div>
26582        <div class="error-inline" id="locate-error">
26583          <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>
26584          <span id="locate-error-text"></span>
26585        </div>
26586        <div class="success-inline" id="locate-success">
26587          <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>
26588          <span>Scan restored &mdash; loading report&hellip;</span>
26589        </div>
26590        <div class="btn-row">
26591          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26592          <a class="btn-secondary" href="/view-reports">View Reports</a>
26593        </div>
26594        <div class="folder-hint-shell">
26595          <div class="folder-hint-hdr">
26596            <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>
26597            Expected Folder Structure &mdash; Select the Top-Level Folder
26598          </div>
26599          <div class="folder-hint-body">
26600            <div class="fh-row">
26601              <span class="fh-tog">&#9658;</span>
26602              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
26603              <span class="fh-badge">&larr; select this</span>
26604            </div>
26605            <div class="fh-row fh-i1">
26606              <span class="fh-tog">&#9658;</span>
26607              <span class="fh-dir">html/</span>
26608            </div>
26609            <div class="fh-row fh-i2">
26610              <span class="fh-bul">&#8226;</span>
26611              <span class="fh-hl">{{ expected_filename }}</span>
26612            </div>
26613            <div class="fh-row fh-i1">
26614              <span class="fh-tog">&#9658;</span>
26615              <span class="fh-dir">json/</span>
26616            </div>
26617            <div class="fh-row fh-i2">
26618              <span class="fh-bul">&#8226;</span>
26619              <span class="fh-muted">result_*.json</span>
26620            </div>
26621            <div class="fh-row fh-i1">
26622              <span class="fh-tog">&#9658;</span>
26623              <span class="fh-dir">pdf/</span>
26624            </div>
26625            <div class="fh-row fh-i2">
26626              <span class="fh-bul">&#8226;</span>
26627              <span class="fh-muted">report_*.pdf</span>
26628            </div>
26629            <div class="fh-row fh-i1">
26630              <span class="fh-tog">&#9658;</span>
26631              <span class="fh-dir">excel/</span>
26632            </div>
26633            <div class="fh-row fh-i2">
26634              <span class="fh-bul">&#8226;</span>
26635              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
26636            </div>
26637          </div>
26638        </div>
26639      </div>
26640    </div>
26641  </div>
26642  <footer class="site-footer">
26643    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
26644    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26645    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26646    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26647    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26648  </footer>
26649  <script nonce="{{ csp_nonce }}">(function(){
26650    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26651    if(s==="dark")b.classList.add("dark-theme");
26652    document.getElementById("theme-toggle").addEventListener("click",function(){
26653      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26654    });
26655  })();</script>
26656  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26657    var c=document.getElementById('code-particles');if(!c)return;
26658    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'];
26659    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);}
26660  })();
26661  (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>
26662  <script nonce="{{ csp_nonce }}">(function(){
26663    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'}];
26664    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);});}
26665    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26666    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');});}
26667    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26668  }());</script>
26669  <script nonce="{{ csp_nonce }}">(function(){
26670    var meta=document.getElementById('locate-meta');
26671    var inp=document.getElementById('locate-file-input');
26672    var browseBtn=document.getElementById('browse-locate-btn');
26673    var submitBtn=document.getElementById('locate-submit-btn');
26674    var warning=document.getElementById('filename-warning');
26675    var errBox=document.getElementById('locate-error');
26676    var errText=document.getElementById('locate-error-text');
26677    var okBox=document.getElementById('locate-success');
26678    var expected=meta?meta.getAttribute('data-expected'):'';
26679    var runId=meta?meta.getAttribute('data-run-id'):'';
26680    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26681    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26682    function showErr(msg){
26683      if(errText){
26684        errText.innerHTML='';
26685        var lines=msg.split('\n');
26686        var hasPairs=lines.some(function(l){return / : /.test(l);});
26687        if(!hasPairs){errText.textContent=msg;}
26688        else{
26689          var frag=document.createDocumentFragment();var tbl=null;
26690          lines.forEach(function(line){
26691            var m=line.match(/^(.*?) : (.*)$/);
26692            if(m){
26693              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26694              var tr=document.createElement('tr');
26695              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26696              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26697              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26698            } else {
26699              tbl=null;
26700              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26701            }
26702          });
26703          errText.appendChild(frag);
26704        }
26705      }
26706      if(errBox)errBox.classList.add('show');
26707      if(okBox)okBox.classList.remove('show');
26708    }
26709    function clearErr(){
26710      if(errBox)errBox.classList.remove('show');
26711      if(okBox)okBox.classList.remove('show');
26712    }
26713    function validate(){
26714      var val=inp?inp.value.trim():'';
26715      clearErr();
26716      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26717      if(submitBtn)submitBtn.disabled=false;
26718      if(warning){
26719        var name=basename(val);
26720        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26721        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26722        else warning.classList.remove('show');
26723      }
26724    }
26725    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26726    if(browseBtn){
26727      browseBtn.addEventListener('click',function(){
26728        browseBtn.disabled=true;browseBtn.textContent='...';
26729        fetch('/pick-directory')
26730          .then(function(r){return r.ok?r.json():{cancelled:true};})
26731          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26732          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26733      });
26734    }
26735    if(submitBtn){
26736      submitBtn.addEventListener('click',function(){
26737        var folder=inp?inp.value.trim():'';
26738        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26739        clearErr();
26740        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26741        var body=new URLSearchParams();
26742        body.set('file_path',folder);
26743        body.set('redirect_url',redirectUrl);
26744        body.set('expected_run_id',runId);
26745        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26746          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26747          .then(function(d){
26748            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26749            if(d&&d.ok){
26750              if(okBox)okBox.classList.add('show');
26751              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26752            } else {
26753              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26754            }
26755          })
26756          .catch(function(e){
26757            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26758            showErr('Network error: '+String(e));
26759          });
26760      });
26761    }
26762  })();</script>
26763  <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>
26764</body>
26765</html>
26766"##,
26767    ext = "html"
26768)]
26769struct LocateFileTemplate {
26770    run_id: String,
26771    artifact_type: String,
26772    expected_filename: String,
26773    server_mode: bool,
26774    csp_nonce: String,
26775    version: &'static str,
26776}
26777
26778// ── RelocateScanTemplate ──────────────────────────────────────────────────────
26779
26780#[derive(Template)]
26781#[template(
26782    source = r##"
26783<!doctype html>
26784<html lang="en">
26785<head>
26786  <meta charset="utf-8">
26787  <meta name="viewport" content="width=device-width, initial-scale=1">
26788  <title>OxideSLOC | Locate Scan Files</title>
26789  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26790  <style nonce="{{ csp_nonce }}">
26791    :root {
26792      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26793      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26794      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26795      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26796    }
26797    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26798    *{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;}
26799    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26800    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26801    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26802    .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);}
26803    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26804    .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));}
26805    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26806    .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;}
26807    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26808    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26809    @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;}}
26810    .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;}
26811    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26812    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26813    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26814    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26815    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26816    .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;}
26817    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26818    .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);}
26819    .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;}
26820    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26821    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26822    .settings-modal-body{padding:14px 16px 16px;}
26823    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26824    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26825    .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;}
26826    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26827    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26828    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26829    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26830    .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;}
26831    .tz-select:focus{border-color:var(--oxide);}
26832    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26833    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26834    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26835    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26836    .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;}
26837    .error-box.hidden{display:none;}
26838    .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;}
26839    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26840    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26841    .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;}
26842    .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;}
26843    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26844    .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;}
26845    .btn-secondary:hover{background:var(--line);}
26846    .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;}
26847    .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;}
26848    .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;}
26849    @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));}}
26850    .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;}
26851    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26852    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26853    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26854    .relocate-row{display:flex;gap:8px;align-items:stretch;}
26855    .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;}
26856    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26857    body.dark-theme .relocate-input{background:var(--surface-2);}
26858  </style>
26859</head>
26860<body>
26861  <div class="background-watermarks" aria-hidden="true">
26862    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26863    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26864    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26865    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26866    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26867    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26868  </div>
26869  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26870  <div class="top-nav">
26871    <div class="top-nav-inner">
26872      <a class="brand" href="/">
26873        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26874        <div class="brand-copy">
26875          <div class="brand-title">OxideSLOC</div>
26876          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26877        </div>
26878      </a>
26879      <div class="nav-right">
26880        <a class="nav-pill" href="/">Home</a>
26881        <div class="nav-dropdown">
26882          <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>
26883          <div class="nav-dropdown-menu">
26884            <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>
26885          </div>
26886        </div>
26887        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26888        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26889        <div class="nav-dropdown">
26890          <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>
26891          <div class="nav-dropdown-menu">
26892            <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>
26893          </div>
26894        </div>
26895        <div class="server-status-wrap" id="server-status-wrap">
26896          <div class="nav-pill server-online-pill" id="server-status-pill">
26897            <span class="status-dot" id="status-dot"></span>
26898            <span id="server-status-label">Server</span>
26899            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26900          </div>
26901          <div class="server-status-tip">
26902            OxideSLOC is running — accessible on your network.
26903            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26904          </div>
26905        </div>
26906        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26907          <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>
26908        </button>
26909        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26910          <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>
26911          <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>
26912        </button>
26913      </div>
26914    </div>
26915  </div>
26916
26917  <div class="page">
26918    <div class="panel">
26919      <h1>Scan Files Moved</h1>
26920      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26921      <div class="error-box" id="relocate-error-box">{{ message }}</div>
26922      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
26923      <div class="relocate-section">
26924        <h2>Locate Scan Output</h2>
26925        <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>
26926        <div class="relocate-row">
26927          <input type="text" id="relocate-folder" name="folder_path"
26928                 value="{{ folder_hint }}"
26929                 placeholder="Path to folder containing scan output..."
26930                 class="relocate-input" autocomplete="off" spellcheck="false">
26931          {% if !server_mode %}
26932          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
26933          {% endif %}
26934        </div>
26935        <div style="margin-top:12px;">
26936          <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26937        </div>
26938      </div>
26939      <div class="actions">
26940        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26941        <a class="btn-secondary" href="/view-reports">View Reports</a>
26942      </div>
26943    </div>
26944  </div>
26945  <footer class="site-footer">
26946    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
26947    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26948    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26949    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26950    &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26951  </footer>
26952  <script nonce="{{ csp_nonce }}">
26953    (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");});})();
26954    (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);}})();
26955    (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;});})();
26956  </script>
26957  <script nonce="{{ csp_nonce }}">
26958  (function(){
26959    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'}];
26960    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);});}
26961    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26962    function init(){
26963      var btn=document.getElementById('settings-btn');if(!btn)return;
26964      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26965      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>';
26966      document.body.appendChild(m);
26967      var g=document.getElementById('scheme-grid');
26968      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);});
26969      var cl=document.getElementById('settings-close');
26970      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);});})();
26971      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');});
26972      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26973      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26974    }
26975    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26976  }());
26977  (function(){
26978    var browseBtn=document.getElementById('browse-relocate-btn');
26979    if(browseBtn){
26980      browseBtn.addEventListener('click',function(){
26981        browseBtn.disabled=true;browseBtn.textContent='...';
26982        var inp=document.getElementById('relocate-folder');
26983        var hint=inp?inp.value:'';
26984        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
26985          .then(function(r){return r.ok?r.json():{cancelled:true};})
26986          .then(function(d){
26987            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26988            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26989          })
26990          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26991      });
26992    }
26993    var restoreBtn=document.getElementById('restore-btn');
26994    var errBox=document.getElementById('relocate-error-box');
26995    var okBox=document.getElementById('relocate-success-box');
26996    if(restoreBtn){
26997      restoreBtn.addEventListener('click',function(){
26998        var inp=document.getElementById('relocate-folder');
26999        var folder=inp?inp.value.trim():'';
27000        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27001        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27002        var body=new URLSearchParams();
27003        body.set('run_id','{{ run_id }}');
27004        body.set('redirect_url','{{ redirect_url }}');
27005        body.set('folder_path',folder);
27006        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27007          .then(function(r){return r.json();})
27008          .then(function(d){
27009            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27010            if(d&&d.ok){
27011              if(errBox)errBox.classList.add('hidden');
27012              if(okBox){okBox.style.display='block';}
27013              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27014            } else {
27015              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27016            }
27017          })
27018          .catch(function(e){
27019            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27020            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27021          });
27022      });
27023    }
27024  }());
27025  </script>
27026  <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]';
27027  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;}
27028  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>
27029</body>
27030</html>
27031"##,
27032    ext = "html"
27033)]
27034struct RelocateScanTemplate {
27035    message: String,
27036    run_id: String,
27037    folder_hint: String,
27038    redirect_url: String,
27039    server_mode: bool,
27040    csp_nonce: String,
27041    version: &'static str,
27042}
27043
27044// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
27045
27046#[derive(Template)]
27047#[template(
27048    source = r##"
27049<!doctype html>
27050<html lang="en">
27051<head>
27052  <meta charset="utf-8">
27053  <meta name="viewport" content="width=device-width, initial-scale=1">
27054  <title>OxideSLOC | View Reports</title>
27055  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27056  <style nonce="{{ csp_nonce }}">
27057    :root {
27058      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27059      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27060      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27061      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27062      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27063    }
27064    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; }
27065    *{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;}
27066    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27067    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27068    .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);}
27069    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27070    .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));}
27071    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27072    .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;}
27073    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27074    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27075    @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; } }
27076    .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;}
27077    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27078    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27079    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27080    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27081    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27082    .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;}
27083    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27084    .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);}
27085    .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;}
27086    .settings-close:hover{color:var(--text);background:var(--surface-2);}
27087    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27088    .settings-modal-body{padding:14px 16px 16px;}
27089    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27090    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27091    .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;}
27092    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27093    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27094    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27095    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27096    .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;}
27097    .tz-select:focus{border-color:var(--oxide);}
27098    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27099    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27100    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27101    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27102    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27103    .panel-meta{font-size:13px;color:var(--muted);}
27104    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27105    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27106    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27107    .per-page-label{font-size:13px;color:var(--muted);}
27108    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;}
27109    .filter-input{min-width:180px;cursor:text;}
27110    .table-wrap{width:100%;overflow-x:auto;}
27111    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27112    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;}
27113    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27114    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27115    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27116    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27117    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27118    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27119    tr:last-child td{border-bottom:none;}
27120    tr:hover td{background:var(--surface-2);}
27121    .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);}
27122    .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);}
27123    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27124    .metric-num{font-weight:700;color:var(--text);}
27125    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27126    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27127    .git-commit-chip{cursor:help;}
27128    .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;}
27129    .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;}
27130    .btn:hover{background:var(--line);}
27131    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27132    .btn.primary:hover{opacity:.9;}
27133    .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;}
27134    .btn-back:hover{background:var(--line);}
27135    .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;}
27136    .export-btn:hover{background:var(--line);}
27137    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27138    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27139    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27140    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27141    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27142    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27143    .pagination-info{font-size:13px;color:var(--muted);}
27144    .pagination-btns{display:flex;gap:6px;}
27145    .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;}
27146    .pg-btn:hover:not(:disabled){background:var(--line);}
27147    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27148    .pg-btn:disabled{opacity:.35;cursor:default;}
27149    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27150    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27151    .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);}
27152    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27153    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27154    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27155    .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);}
27156    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27157    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27158    .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;}
27159    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27160    .site-footer a{color:var(--muted);}
27161    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27162    .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%;}
27163    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27164    .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;}
27165    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27166    .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;}
27167    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27168    .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;}
27169    .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;}
27170    .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;}
27171    @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));}}
27172    .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;}
27173    .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;}
27174    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27175    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27176    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27177    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27178    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27179    .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;}
27180    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27181    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27182    .watched-chip-rm:hover{color:var(--oxide);}
27183    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27184    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27185    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27186    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27187    .rpt-btn{min-width:58px;justify-content:center;}
27188    .flex-row{display:flex;align-items:center;gap:8px;}
27189    .report-cell{overflow:visible;white-space:normal;}
27190    #history-table col:nth-child(1){width:185px;}
27191    #history-table col:nth-child(2){width:220px;}
27192    #history-table col:nth-child(3){width:100px;}
27193    #history-table col:nth-child(4){width:72px;}
27194    #history-table col:nth-child(5){width:82px;}
27195    #history-table col:nth-child(6){width:82px;}
27196    #history-table col:nth-child(7){width:65px;}
27197    #history-table col:nth-child(8){width:90px;}
27198    #history-table col:nth-child(9){width:85px;}
27199    #history-table col:nth-child(10){width:115px;}
27200    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27201    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27202    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27203    .submod-details summary::-webkit-details-marker{display:none;}
27204.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27205    .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;}
27206    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27207    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27208  </style>
27209</head>
27210<body>
27211  <div class="background-watermarks" aria-hidden="true">
27212    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27213    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27214    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27215    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27216    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27217    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27218  </div>
27219  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27220  <div class="top-nav">
27221    <div class="top-nav-inner">
27222      <a class="brand" href="/">
27223        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27224        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27225      </a>
27226      <div class="nav-right">
27227        <a class="nav-pill" href="/">Home</a>
27228        <div class="nav-dropdown">
27229          <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>
27230          <div class="nav-dropdown-menu">
27231            <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>
27232          </div>
27233        </div>
27234        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27235        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27236        <div class="nav-dropdown">
27237          <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>
27238          <div class="nav-dropdown-menu">
27239            <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>
27240          </div>
27241        </div>
27242        <div class="server-status-wrap" id="server-status-wrap">
27243          <div class="nav-pill server-online-pill" id="server-status-pill">
27244            <span class="status-dot" id="status-dot"></span>
27245            <span id="server-status-label">Server</span>
27246            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27247          </div>
27248          <div class="server-status-tip">
27249            OxideSLOC is running — accessible on your network.
27250            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27251          </div>
27252        </div>
27253        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27254          <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>
27255        </button>
27256        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27257          <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>
27258          <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>
27259        </button>
27260      </div>
27261    </div>
27262  </div>
27263
27264  <div class="page">
27265    {% if let Some(err) = browse_error %}
27266    <div class="toast-error">
27267      <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>
27268      {{ err }}
27269    </div>
27270    {% endif %}
27271    {% if linked_count > 0 %}
27272    <div class="toast-success">
27273      <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>
27274      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27275    </div>
27276    {% endif %}
27277    <div class="watched-bar">
27278      <div class="watched-bar-left">
27279        <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>
27280        <span class="watched-label">Watched Folders</span>
27281        <div class="watched-chips">
27282          {% if server_mode %}
27283          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27284          {% else %}
27285          {% for dir in watched_dirs %}
27286          <span class="watched-chip">
27287            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27288            <form method="POST" action="/watched-dirs/remove" style="display:contents">
27289              <input type="hidden" name="folder_path" value="{{ dir }}">
27290              <input type="hidden" name="redirect_to" value="/view-reports">
27291              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
27292            </form>
27293          </span>
27294          {% endfor %}
27295          {% if watched_dirs.is_empty() %}
27296          <span class="watched-none">No folders watched — click Choose to add one</span>
27297          {% endif %}
27298          {% endif %}
27299        </div>
27300      </div>
27301      {% if !server_mode %}
27302      <div class="watched-bar-right">
27303        <button type="button" class="btn" id="add-watched-btn">
27304          <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>
27305          Choose
27306        </button>
27307        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27308          <input type="hidden" name="redirect_to" value="/view-reports">
27309          <button type="submit" class="btn">&#8635; Refresh</button>
27310        </form>
27311      </div>
27312      {% endif %}
27313    </div>
27314    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27315      <div class="scan-overlay-card">
27316        <div class="scan-spinner"></div>
27317        <div class="scan-overlay-text">Scanning folder…</div>
27318        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27319      </div>
27320    </div>
27321    <style>
27322    .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);}
27323    .scan-overlay.active{display:flex;}
27324    .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;}
27325    .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;}
27326    @keyframes scanSpin{to{transform:rotate(360deg);}}
27327    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27328    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27329    </style>
27330    {% if total_scans > 0 %}
27331    <div class="summary-strip">
27332      <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>
27333      <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>
27334      <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>
27335      <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>
27336    </div>
27337    {% endif %}
27338
27339    <section class="panel">
27340      <div class="panel-header">
27341        <div>
27342          <h1>View Reports</h1>
27343          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27344          {% 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 %}
27345        </div>
27346        <div class="flex-row">
27347          <button type="button" class="export-btn" id="export-csv-btn">
27348            <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>
27349            Export CSV
27350          </button>
27351          <button type="button" class="export-btn" id="export-xls-btn">
27352            <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>
27353            Export Excel
27354          </button>
27355        </div>
27356      </div>
27357
27358      {% if entries.is_empty() %}
27359      <div class="empty-state">
27360        <strong>No reports with viewable HTML yet</strong>
27361        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.
27362      </div>
27363      {% else %}
27364      <div class="filter-row">
27365        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
27366        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27367        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
27368      </div>
27369      <div class="table-wrap">
27370        <table id="history-table">
27371          <colgroup>
27372            <col><col><col><col><col><col><col><col><col><col>
27373          </colgroup>
27374          <thead>
27375            <tr id="history-thead">
27376              <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>
27377              <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>
27378              <th>Run ID<div class="col-resize-handle"></div></th>
27379              <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>
27380              <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>
27381              <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>
27382              <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>
27383              <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>
27384              <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>
27385              <th>Report<div class="col-resize-handle"></div></th>
27386            </tr>
27387          </thead>
27388          <tbody id="history-tbody">
27389            {% for entry in entries %}
27390            <tr class="history-row" data-run="{{ entry.run_id }}"
27391                data-timestamp="{{ entry.timestamp }}"
27392                data-project="{{ entry.project_label }}"
27393                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27394                data-skipped="{{ entry.files_skipped }}"
27395                data-comments="{{ entry.comment_lines }}"
27396                data-blank="{{ entry.blank_lines }}"
27397                data-physical="{{ entry.total_physical_lines }}"
27398                data-functions="{{ entry.functions }}"
27399                data-classes="{{ entry.classes }}"
27400                data-variables="{{ entry.variables }}"
27401                data-imports="{{ entry.imports }}"
27402                data-tests="{{ entry.test_count }}"
27403                data-branch="{{ entry.git_branch }}"
27404                data-commit="{{ entry.git_commit }}"
27405                data-has-json="{{ entry.has_json }}"
27406                data-html-url="/runs/html/{{ entry.run_id }}">
27407              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27408              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27409              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27410              <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>
27411              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27412              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27413              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27414              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
27415              <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>
27416              <td class="report-cell">
27417                <div class="actions-cell">
27418                  {% 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 %}
27419                  {% 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 %}
27420                </div>
27421                {% if !entry.submodule_links.is_empty() %}
27422                <details class="submod-details">
27423                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
27424                  <div class="submod-link-list">
27425                    {% for sub in entry.submodule_links %}
27426                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27427                    {% endfor %}
27428                  </div>
27429                </details>
27430                {% endif %}
27431              </td>
27432            </tr>
27433            {% endfor %}
27434          </tbody>
27435        </table>
27436      </div>
27437      <div class="pagination">
27438        <span class="pagination-info" id="pagination-info"></span>
27439        <div class="pagination-btns" id="pagination-btns"></div>
27440        <div class="flex-row">
27441          <span class="per-page-label">Show</span>
27442          <select class="per-page" id="per-page-sel">
27443            <option value="10">10 per page</option>
27444            <option value="25" selected>25 per page</option>
27445            <option value="50">50 per page</option>
27446            <option value="100">100 per page</option>
27447          </select>
27448          <span class="per-page-label" id="page-range-label"></span>
27449        </div>
27450      </div>
27451      {% endif %}
27452    </section>
27453  </div>
27454
27455  <footer class="site-footer">
27456    local code analysis - metrics, history and reports
27457    &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>
27458    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27459    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27460    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27461    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
27462  </footer>
27463
27464  <script nonce="{{ csp_nonce }}">
27465    (function () {
27466      // ── Theme ──────────────────────────────────────────────────────────────
27467      var storageKey = 'oxide-sloc-theme';
27468      var body = document.body;
27469      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27470      var toggle = document.getElementById('theme-toggle');
27471      if (toggle) toggle.addEventListener('click', function () {
27472        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27473        body.classList.toggle('dark-theme', next === 'dark');
27474        try { localStorage.setItem(storageKey, next); } catch(e) {}
27475      });
27476
27477      // ── State ─────────────────────────────────────────────────────────────
27478      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27479      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27480      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27481
27482      // Aggregate stats from first (most recent) row
27483      if (allRows.length) {
27484        var first = allRows[0];
27485        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();}
27486        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>':'');}
27487        setChipVal('agg-code', first.dataset.code);
27488        setChipVal('agg-files', first.dataset.files);
27489        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27490        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27491        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(); });
27492      }
27493
27494      // ── Branch filter population ──────────────────────────────────────────
27495      (function() {
27496        var branches = {};
27497        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27498        var sel = document.getElementById('branch-filter');
27499        if (sel) Object.keys(branches).sort().forEach(function(b) {
27500          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27501        });
27502      })();
27503
27504      // ── Filter ────────────────────────────────────────────────────────────
27505      function getFilteredRows() {
27506        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27507        var branch = ((document.getElementById('branch-filter') || {}).value || '');
27508        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27509          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27510          if (branch && (r.dataset.branch || '') !== branch) return false;
27511          return true;
27512        });
27513      }
27514
27515      // ── Pagination ────────────────────────────────────────────────────────
27516      function renderPage() {
27517        var filtered = getFilteredRows();
27518        var total = filtered.length;
27519        var totalPages = Math.max(1, Math.ceil(total / perPage));
27520        currentPage = Math.min(currentPage, totalPages);
27521        var start = (currentPage - 1) * perPage;
27522        var end = Math.min(start + perPage, total);
27523        var shown = {};
27524        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27525        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27526          r.style.display = shown[r.dataset.run] ? '' : 'none';
27527        });
27528        var rl = document.getElementById('page-range-label');
27529        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27530        var info = document.getElementById('pagination-info');
27531        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27532        var btns = document.getElementById('pagination-btns');
27533        if (!btns) return;
27534        btns.innerHTML = '';
27535        function makeBtn(lbl, pg, active, disabled) {
27536          var b = document.createElement('button');
27537          b.className = 'pg-btn' + (active ? ' active' : '');
27538          b.textContent = lbl; b.disabled = disabled;
27539          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27540          return b;
27541        }
27542        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27543        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27544        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27545        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27546      }
27547
27548      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27549      window.applyFilters = function() { currentPage = 1; renderPage(); };
27550
27551      // ── Sorting ───────────────────────────────────────────────────────────
27552      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27553      function doSort(col, type, order) {
27554        var tbody = document.getElementById('history-tbody');
27555        if (!tbody) return;
27556        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27557        rows.sort(function(a, b) {
27558          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27559          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27560          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27561          return va < vb ? 1 : va > vb ? -1 : 0;
27562        });
27563        rows.forEach(function(r) { tbody.appendChild(r); });
27564        currentPage = 1; renderPage();
27565      }
27566      sortHeaders.forEach(function(th) {
27567        th.addEventListener('click', function(e) {
27568          if (e.target.classList.contains('col-resize-handle')) return;
27569          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27570          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27571          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27572          th.classList.add('sort-' + sortOrder);
27573          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27574          doSort(col, type, sortOrder);
27575        });
27576      });
27577
27578      // ── Column resize ─────────────────────────────────────────────────────
27579      (function() {
27580        var table = document.getElementById('history-table');
27581        if (!table) return;
27582        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27583        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27584        ths.forEach(function(th, i) {
27585          var handle = th.querySelector('.col-resize-handle');
27586          if (!handle || !cols[i]) return;
27587          var startX, startW;
27588          handle.addEventListener('mousedown', function(e) {
27589            e.stopPropagation(); e.preventDefault();
27590            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27591            handle.classList.add('dragging');
27592            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27593            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27594            document.addEventListener('mousemove', onMove);
27595            document.addEventListener('mouseup', onUp);
27596          });
27597        });
27598      })();
27599
27600      // ── Full-commit hover tooltip ─────────────────────────────────────────
27601      // The commit chips live inside an overflow:auto table wrapper, which would
27602      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27603      // (escaping the scroll container) and follow the cursor. Event delegation
27604      // keeps it working after pagination/sorting re-renders the rows.
27605      (function() {
27606        var tip = document.createElement('div');
27607        tip.className = 'commit-tip';
27608        tip.setAttribute('role', 'tooltip');
27609        document.body.appendChild(tip);
27610        var shown = false;
27611        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27612        function place(e) {
27613          var pad = 14, r = tip.getBoundingClientRect();
27614          var x = e.clientX + pad, y = e.clientY + pad;
27615          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27616          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27617          tip.style.left = x + 'px'; tip.style.top = y + 'px';
27618        }
27619        function hide() { tip.style.display = 'none'; shown = false; }
27620        document.addEventListener('mouseover', function(e) {
27621          var chip = chipFrom(e.target);
27622          if (!chip) return;
27623          var full = chip.getAttribute('data-full-commit');
27624          if (!full) return;
27625          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27626        });
27627        document.addEventListener('mousemove', function(e) {
27628          if (!shown) return;
27629          if (chipFrom(e.target)) place(e); else hide();
27630        });
27631        document.addEventListener('mouseout', function(e) {
27632          if (chipFrom(e.target)) hide();
27633        });
27634      })();
27635
27636      // ── Reset view ────────────────────────────────────────────────────────
27637      window.resetView = function() {
27638        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27639        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27640        sortCol = null; sortOrder = 'asc';
27641        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27642        var tbody = document.getElementById('history-tbody');
27643        if (tbody) {
27644          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27645          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27646          rows.forEach(function(r) { tbody.appendChild(r); });
27647        }
27648        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27649        var table = document.getElementById('history-table');
27650        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27651        currentPage = 1; renderPage();
27652      };
27653
27654      renderPage();
27655
27656      // ── Export helpers ────────────────────────────────────────────────────
27657      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
27658      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27659      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);}
27660      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;');}
27661      function slocXlsx(fname,sheet,hdrs,rows){
27662        var enc=new TextEncoder();
27663        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;}
27664        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;}
27665        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27666        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27667        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27668        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;}
27669        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27670        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];}
27671        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27672        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27673        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27674          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27675          +'<fonts count="2">'
27676            +'<font><sz val="11"/><name val="Calibri"/></font>'
27677            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27678          +'</fonts>'
27679          +'<fills count="3">'
27680            +'<fill><patternFill patternType="none"/></fill>'
27681            +'<fill><patternFill patternType="gray125"/></fill>'
27682            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27683          +'</fills>'
27684          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27685          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27686          +'<cellXfs count="4">'
27687            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27688            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27689            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27690            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27691          +'</cellXfs>'
27692          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27693          +'</styleSheet>';
27694        var rx='<row r="1">';
27695        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27696        rx+='</row>';
27697        rows.forEach(function(row,ri){
27698          var rn=ri+2;rx+='<row r="'+rn+'">';
27699          row.forEach(function(cell,c){
27700            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27701            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27702            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27703            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27704            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27705            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27706          });
27707          rx+='</row>';
27708        });
27709        var lastCol=hdrs.length,lastRow=rows.length+1;
27710        var tableRef='A1:'+colNm(lastCol)+lastRow;
27711        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27712          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27713          +'<autoFilter ref="'+tableRef+'"/>'
27714          +'<tableColumns count="'+lastCol+'">'
27715          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27716          +'</tableColumns>'
27717          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27718          +'</table>';
27719        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27720          +'<Relationships xmlns="'+pns+'relationships">'
27721          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27722          +'</Relationships>';
27723        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>';
27724        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27725          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27726          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27727          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27728          +'</worksheet>';
27729        var F={
27730          '[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>',
27731          '_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>',
27732          '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>',
27733          '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>',
27734          'xl/styles.xml':stl,
27735          'xl/sharedStrings.xml':ssXml,
27736          'xl/worksheets/sheet1.xml':sh,
27737          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27738          'xl/tables/table1.xml':tableXml
27739        };
27740        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'];
27741        var zparts=[],zcds=[],zoff=0,znf=0;
27742        order.forEach(function(name){
27743          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27744          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]);
27745          var entry=new Uint8Array(lha.length+nb.length+sz);
27746          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27747          zparts.push(entry);
27748          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));
27749          var cde=new Uint8Array(cda.length+nb.length);
27750          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27751          zcds.push(cde);zoff+=entry.length;znf++;
27752        });
27753        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27754        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]);
27755        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27756        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27757        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27758        zout.set(new Uint8Array(ea),zpos);
27759        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27760      }
27761
27762      // Multi-sheet XLSX builder for the scan-history export.
27763      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27764      function slocXlsxMulti(fname,sheets){
27765        var enc=new TextEncoder();
27766        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;}
27767        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;}
27768        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27769        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27770        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
27771        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];}
27772        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;}
27773        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27774        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27775        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27776          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27777          +'<fonts count="3">'
27778            +'<font><sz val="11"/><name val="Calibri"/></font>'
27779            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27780            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27781          +'</fonts>'
27782          +'<fills count="4">'
27783            +'<fill><patternFill patternType="none"/></fill>'
27784            +'<fill><patternFill patternType="gray125"/></fill>'
27785            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27786            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27787          +'</fills>'
27788          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27789          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27790          +'<cellXfs count="7">'
27791            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27792            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27793            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27794            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27795            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27796            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27797            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27798          +'</cellXfs>'
27799          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27800          +'</styleSheet>';
27801        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27802        sheets.forEach(function(sh,sheetIdx){
27803          var rx='<row r="1">';
27804          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27805          rx+='</row>';
27806          var rn=2;
27807          sh.rows.forEach(function(row){
27808            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27809            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27810              rx+='<row r="'+rn+'">';
27811              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27812              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27813              rx+='</row>';rn++;return;
27814            }
27815            rx+='<row r="'+rn+'">';
27816            row.forEach(function(cell,c){
27817              var ref=colRef(c,rn);
27818              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27819              if(typeof cell==='object'&&cell!==null){
27820                var cv=cell.v,cs=cell.s!=null?cell.s:0;
27821                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27822                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27823                return;
27824              }
27825              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27826              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27827            });
27828            rx+='</row>';rn++;
27829          });
27830          var cw='';
27831          if(sh.colWidths&&sh.colWidths.length>0){
27832            cw='<cols>';
27833            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27834            cw+='</cols>';
27835          }
27836          var tblParts='';
27837          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27838            tableCounter++;
27839            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27840            var tRef='A1:'+colNm(colCount)+rowCount;
27841            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27842              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27843              +'<autoFilter ref="'+tRef+'"/>'
27844              +'<tableColumns count="'+colCount+'">'
27845              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27846              +'</tableColumns>'
27847              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27848              +'</table>';
27849            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27850              +'<Relationships xmlns="'+pns+'relationships">'
27851              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27852              +'</Relationships>';
27853            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27854          }
27855          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27856            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27857            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27858        });
27859        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>';
27860        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27861        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27862        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>';
27863        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27864        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27865        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27866        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27867          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27868        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27869        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};
27870        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27871        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27872        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27873        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27874        var zparts=[],zcds=[],zoff=0,znf=0;
27875        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++;});
27876        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27877        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]);
27878        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27879        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27880        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27881        zout.set(new Uint8Array(ea),zpos);
27882        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27883      }
27884
27885      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'};
27886      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27887
27888      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'];
27889      function getHistoryRows(){
27890        var r=[];
27891        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27892          var code=Number(tr.getAttribute('data-code'))||0;
27893          var phys=Number(tr.getAttribute('data-physical'))||0;
27894          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27895          r.push([
27896            tr.getAttribute('data-timestamp')||'',
27897            tr.getAttribute('data-project')||'',
27898            tr.getAttribute('data-run')||'',
27899            tr.getAttribute('data-physical')||'',
27900            tr.getAttribute('data-code')||'',
27901            tr.getAttribute('data-comments')||'',
27902            tr.getAttribute('data-blank')||'',
27903            tr.getAttribute('data-files')||'',
27904            tr.getAttribute('data-skipped')||'',
27905            tr.getAttribute('data-functions')||'',
27906            tr.getAttribute('data-classes')||'',
27907            tr.getAttribute('data-variables')||'',
27908            tr.getAttribute('data-imports')||'',
27909            tr.getAttribute('data-tests')||'',
27910            dens,
27911            tr.getAttribute('data-branch')||'',
27912            tr.getAttribute('data-commit')||''
27913          ]);
27914        });
27915        return r;
27916      }
27917      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27918      window.exportHistoryXls = function(){
27919        var histRows=getHistoryRows();
27920        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27921        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]];});
27922        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]};
27923        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27924        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27925        var runId=jsonRow.getAttribute('data-run')||'';
27926        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27927        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27928        fetch('/runs/json/'+runId)
27929          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27930          .then(function(run){
27931            var tot=run.summary_totals||{};
27932            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27933            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27934            function B(v){return{v:v,s:4};}
27935            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27936            var sumRows=[
27937              [{_sec:true,v:'RUN INFORMATION'}],
27938              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27939              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27940              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27941              [B('Branch'),run.git_branch||''],
27942              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27943              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27944              [B('Files Analyzed'),N(tot.files_analyzed)],
27945              [B('Files Skipped'),N(tot.files_skipped)],
27946              [],
27947              [{_sec:true,v:'CODE METRICS'}],
27948              [B('Physical Lines'),N(phys)],
27949              [B('Code Lines'),N(code)],
27950              [B('Comments'),N(tot.comment_lines)],
27951              [B('Blank Lines'),N(tot.blank_lines)],
27952              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27953              [B('Functions'),N(tot.functions)],
27954              [B('Classes / Types'),N(tot.classes)],
27955              [B('Variables'),N(tot.variables)],
27956              [B('Imports'),N(tot.imports)],
27957              [B('Tests'),N(tot.test_count)],
27958              [B('Assertions'),N(tot.test_assertion_count)],
27959              [B('Test Suites'),N(tot.test_suite_count)],
27960              [B('Code Density'),{v:dens,s:6}],
27961              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27962            ];
27963            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27964            var langRows=(run.totals_by_language||[]).map(function(l){
27965              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27966              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27967              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];
27968            });
27969            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27970            var pfRows=(run.per_file_records||[]).map(function(r){
27971              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27972              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];
27973            });
27974            var skHdrs=['File','Status','Size (bytes)'];
27975            var skRows=(run.skipped_file_records||[]).map(function(r){
27976              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27977            });
27978            slocXlsxMulti('scan-history.xlsx',[
27979              histSheet,
27980              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27981              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27982              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27983              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27984            ]);
27985          })
27986          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27987      };
27988
27989      var csvBtn = document.getElementById('export-csv-btn');
27990      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27991      var xlsBtn = document.getElementById('export-xls-btn');
27992      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27993
27994      // ── Remaining CSP-safe event bindings ────────────────────────────────
27995      (function wireEvents() {
27996        var el;
27997        el = document.getElementById('reset-view-btn');
27998        if (el) el.addEventListener('click', window.resetView);
27999        el = document.getElementById('project-filter');
28000        if (el) el.addEventListener('input', window.applyFilters);
28001        el = document.getElementById('branch-filter');
28002        if (el) el.addEventListener('change', window.applyFilters);
28003        el = document.getElementById('per-page-sel');
28004        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28005        (function(){
28006          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');};
28007          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);
28008        })();
28009        el = document.getElementById('add-watched-btn');
28010        if (el) el.addEventListener('click', function() {
28011          fetch('/pick-directory?kind=reports')
28012            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28013            .then(function(data) {
28014              if (!data.cancelled && data.selected_path) {
28015                var form = document.createElement('form');
28016                form.method = 'POST';
28017                form.action = '/watched-dirs/add';
28018                var ri = document.createElement('input');
28019                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28020                var fi = document.createElement('input');
28021                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28022                form.appendChild(ri); form.appendChild(fi);
28023                document.body.appendChild(form);
28024                if (window.__scanOverlay) window.__scanOverlay();
28025                form.submit();
28026              }
28027            })
28028            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28029        });
28030      })();
28031
28032      (function randomizeWatermarks() {
28033        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28034        if (!wms.length) return;
28035        var placed = [];
28036        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;}
28037        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];}
28038        var half=Math.floor(wms.length/2);
28039        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;});
28040      })();
28041
28042      (function spawnCodeParticles() {
28043        var container = document.getElementById('code-particles');
28044        if (!container) return;
28045        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'];
28046        for (var i = 0; i < 38; i++) {
28047          (function(idx) {
28048            var el = document.createElement('span');
28049            el.className = 'code-particle';
28050            el.textContent = snippets[idx % snippets.length];
28051            var left = Math.random() * 94 + 2;
28052            var top = Math.random() * 88 + 6;
28053            var dur = (Math.random() * 10 + 9).toFixed(1);
28054            var delay = (Math.random() * 18).toFixed(1);
28055            var rot = (Math.random() * 26 - 13).toFixed(1);
28056            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28057            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';
28058            container.appendChild(el);
28059          })(i);
28060        }
28061      })();
28062    })();
28063  </script>
28064  <script nonce="{{ csp_nonce }}">
28065  (function(){
28066    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'}];
28067    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);});}
28068    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28069    function init(){
28070      var btn=document.getElementById('settings-btn');if(!btn)return;
28071      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28072      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>';
28073      document.body.appendChild(m);
28074      var g=document.getElementById('scheme-grid');
28075      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);});
28076      var cl=document.getElementById('settings-close');
28077      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);});})();
28078      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');});
28079      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28080      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28081    }
28082    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28083  }());
28084  </script>
28085  <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>
28086</body>
28087</html>
28088"##,
28089    ext = "html"
28090)]
28091struct HistoryTemplate {
28092    version: &'static str,
28093    entries: Vec<HistoryEntryRow>,
28094    total_scans: usize,
28095    linked_count: usize,
28096    browse_error: Option<String>,
28097    watched_dirs: Vec<String>,
28098    csp_nonce: String,
28099    server_mode: bool,
28100}
28101
28102// ── CompareSelectTemplate ──────────────────────────────────────────────────────
28103
28104#[derive(Template)]
28105#[template(
28106    source = r##"
28107<!doctype html>
28108<html lang="en">
28109<head>
28110  <meta charset="utf-8">
28111  <meta name="viewport" content="width=device-width, initial-scale=1">
28112  <title>OxideSLOC | Compare Scans</title>
28113  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28114  <style nonce="{{ csp_nonce }}">
28115    :root {
28116      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28117      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28118      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28119      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28120      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28121    }
28122    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28123    *{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;}
28124    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28125    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28126    .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);}
28127    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28128    .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));}
28129    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28130    .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;}
28131    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28132    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28133    @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; } }
28134    .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;}
28135    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28136    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28137    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28138    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28139    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28140    .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;}
28141    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28142    .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);}
28143    .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;}
28144    .settings-close:hover{color:var(--text);background:var(--surface-2);}
28145    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28146    .settings-modal-body{padding:14px 16px 16px;}
28147    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28148    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28149    .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;}
28150    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28151    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28152    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28153    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28154    .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;}
28155    .tz-select:focus{border-color:var(--oxide);}
28156    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28157    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28158    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28159    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28160    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28161    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28162    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28163    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28164    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28165    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28166    .per-page-label{font-size:13px;color:var(--muted);}
28167    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;}
28168    .filter-input{min-width:180px;cursor:text;}
28169    .table-wrap{width:100%;overflow-x:auto;}
28170    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28171    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;}
28172    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28173    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28174    #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;}
28175    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28176    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28177    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28178    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28179    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28180    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28181    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28182    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28183    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28184    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28185    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28186    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28187    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28188    tr:last-child td{border-bottom:none;}
28189    tr.selected td{background:var(--sel-bg);}
28190    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28191    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28192    tr{cursor:pointer;}
28193    tr.row-locked{opacity:.35;cursor:not-allowed;}
28194    tr.row-locked td{pointer-events:none;}
28195    .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;}
28196    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28197    .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;}
28198    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28199    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28200    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28201    .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);}
28202    .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);}
28203    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28204    .metric-num{font-weight:700;color:var(--text);}
28205    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28206    .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;}
28207    .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;}
28208    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28209    .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;}
28210    .btn:hover{background:var(--line);}
28211    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28212    .btn.primary:hover{opacity:.9;}
28213    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28214    .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;}
28215    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28216    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28217    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28218    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28219    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28220    .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;}
28221    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28222    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28223    .watched-chip-rm:hover{color:var(--oxide);}
28224    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28225    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28226    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28227    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28228    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28229    .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;}
28230    .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;}
28231    .btn-back:hover{background:var(--line);}
28232    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28233    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28234    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28235    .pagination-info{font-size:13px;color:var(--muted);}
28236    .pagination-btns{display:flex;gap:6px;}
28237    .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;}
28238    .pg-btn:hover:not(:disabled){background:var(--line);}
28239    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28240    .pg-btn:disabled{opacity:.35;cursor:default;}
28241    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28242    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28243    .site-footer a{color:var(--muted);}
28244    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28245    .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;}
28246    .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;}
28247    .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;}
28248    @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));}}
28249    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28250    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28251    .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);}
28252    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28253    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28254    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28255    .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);}
28256    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28257    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28258    .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;}
28259    .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;}
28260    .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%;}
28261    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28262    .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;}
28263    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28264    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28265    .hidden{display:none!important;}
28266    .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%;}
28267    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28268    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28269    .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;}
28270    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28271    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28272    .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;}
28273    .scope-option:hover{background:var(--line);}
28274    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28275    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28276    .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;}
28277    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28278    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28279    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28280    .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;}
28281  </style>
28282</head>
28283<body>
28284  <div class="background-watermarks" aria-hidden="true">
28285    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28286    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28287    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28288    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28289    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28290    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28291  </div>
28292  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28293  <div class="top-nav">
28294    <div class="top-nav-inner">
28295      <a class="brand" href="/">
28296        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28297        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28298      </a>
28299      <div class="nav-right">
28300        <a class="nav-pill" href="/">Home</a>
28301        <div class="nav-dropdown">
28302          <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>
28303          <div class="nav-dropdown-menu">
28304            <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>
28305          </div>
28306        </div>
28307        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28308        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28309        <div class="nav-dropdown">
28310          <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>
28311          <div class="nav-dropdown-menu">
28312            <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>
28313          </div>
28314        </div>
28315        <div class="server-status-wrap" id="server-status-wrap">
28316          <div class="nav-pill server-online-pill" id="server-status-pill">
28317            <span class="status-dot" id="status-dot"></span>
28318            <span id="server-status-label">Server</span>
28319            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28320          </div>
28321          <div class="server-status-tip">
28322            OxideSLOC is running — accessible on your network.
28323            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28324          </div>
28325        </div>
28326        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28327          <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>
28328        </button>
28329        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28330          <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>
28331          <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>
28332        </button>
28333      </div>
28334    </div>
28335  </div>
28336
28337  <div class="page">
28338    <div class="watched-bar">
28339      <div class="watched-bar-left">
28340        <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>
28341        <span class="watched-label">Watched Folders</span>
28342        <div class="watched-chips">
28343          {% if server_mode %}
28344          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28345          {% else %}
28346          {% for dir in watched_dirs %}
28347          <span class="watched-chip">
28348            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28349            <form method="POST" action="/watched-dirs/remove" style="display:contents">
28350              <input type="hidden" name="folder_path" value="{{ dir }}">
28351              <input type="hidden" name="redirect_to" value="/compare-scans">
28352              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
28353            </form>
28354          </span>
28355          {% endfor %}
28356          {% if watched_dirs.is_empty() %}
28357          <span class="watched-none">No folders watched — click Choose to add one</span>
28358          {% endif %}
28359          {% endif %}
28360        </div>
28361      </div>
28362      {% if !server_mode %}
28363      <div class="watched-bar-right">
28364        <button type="button" class="btn" id="add-watched-btn">
28365          <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>
28366          Choose
28367        </button>
28368        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28369          <input type="hidden" name="redirect_to" value="/compare-scans">
28370          <button type="submit" class="btn">&#8635; Refresh</button>
28371        </form>
28372      </div>
28373      {% endif %}
28374    </div>
28375    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28376      <div class="scan-overlay-card">
28377        <div class="scan-spinner"></div>
28378        <div class="scan-overlay-text">Scanning folder…</div>
28379        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28380      </div>
28381    </div>
28382    <style>
28383    .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);}
28384    .scan-overlay.active{display:flex;}
28385    .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;}
28386    .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;}
28387    @keyframes scanSpin{to{transform:rotate(360deg);}}
28388    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28389    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28390    </style>
28391    {% if total_scans > 0 %}
28392    <div class="summary-strip">
28393      <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>
28394      <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>
28395      <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>
28396      <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>
28397    </div>
28398    {% endif %}
28399    <section class="panel">
28400      <div class="panel-header">
28401        <div>
28402          <h1>Compare Scans</h1>
28403          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28404        </div>
28405        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28406          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28407            <button class="btn primary" id="compare-btn" disabled>
28408              <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>
28409              Compare <span class="sel-count" id="sel-count">0</span> Selected
28410            </button>
28411          </div>
28412        </div>
28413      </div>
28414
28415      {% if entries.is_empty() %}
28416      <div class="empty-state">
28417        <strong>No scans yet</strong>
28418        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.
28419      </div>
28420      {% else %}
28421      <div class="filter-row">
28422        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
28423        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28424        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
28425      </div>
28426      <div class="scope-panel hidden" id="scope-panel">
28427        <div class="scope-panel-label">
28428          <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>
28429          Compare scope — choose what to include
28430        </div>
28431        <div class="scope-options" id="scope-options"></div>
28432      </div>
28433      {% if total_scans > 0 %}
28434      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28435        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28436          <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>
28437          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28438        </div>
28439      </div>
28440      {% endif %}
28441      <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28442        <span class="compare-all-label">
28443          <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>
28444          Quick Compare All
28445        </span>
28446      </div>
28447      <div class="table-wrap">
28448        <table id="compare-table">
28449          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28450          <thead>
28451            <tr id="compare-thead">
28452              <th><div class="col-resize-handle"></div></th>
28453              <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>
28454              <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>
28455              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28456              <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>
28457              <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>
28458              <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>
28459              <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>
28460              <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>
28461              <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>
28462              <th>Submodules<div class="col-resize-handle"></div></th>
28463            </tr>
28464          </thead>
28465          <tbody id="compare-tbody">
28466            {% for entry in entries %}
28467            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28468                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28469                data-project="{{ entry.project_label }}"
28470                data-files="{{ entry.files_analyzed }}"
28471                data-code="{{ entry.code_lines }}"
28472                data-comments="{{ entry.comment_lines }}"
28473                data-blank="{{ entry.blank_lines }}"
28474                data-branch="{{ entry.git_branch }}"
28475                data-commit="{{ entry.git_commit }}"
28476                data-submodules="{{ entry.submodule_names_csv }}">
28477              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28478              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28479              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28480              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28481              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28482              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28483              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28484              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28485              <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>
28486              <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>
28487              <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>
28488            </tr>
28489            {% endfor %}
28490          </tbody>
28491        </table>
28492      </div>
28493      <div class="pagination">
28494        <span class="pagination-info" id="pagination-info"></span>
28495        <div class="pagination-btns" id="pagination-btns"></div>
28496        <div class="flex-row">
28497          <span class="per-page-label">Show</span>
28498          <select class="per-page" id="per-page-sel">
28499            <option value="10">10 per page</option>
28500            <option value="25" selected>25 per page</option>
28501            <option value="50">50 per page</option>
28502            <option value="100">100 per page</option>
28503          </select>
28504          <span class="per-page-label" id="page-range-label"></span>
28505        </div>
28506      </div>
28507      {% endif %}
28508    </section>
28509  </div>
28510
28511  <footer class="site-footer">
28512    local code analysis - metrics, history and reports
28513    &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>
28514    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28515    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28516    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28517    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28518  </footer>
28519
28520  <script nonce="{{ csp_nonce }}">
28521    (function () {
28522      // ── Theme ──────────────────────────────────────────────────────────────
28523      var storageKey = 'oxide-sloc-theme';
28524      var body = document.body;
28525      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28526      var toggle = document.getElementById('theme-toggle');
28527      if (toggle) toggle.addEventListener('click', function () {
28528        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28529        body.classList.toggle('dark-theme', next === 'dark');
28530        try { localStorage.setItem(storageKey, next); } catch(e) {}
28531      });
28532
28533      // ── State ─────────────────────────────────────────────────────────────
28534      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28535      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28536      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28537      window._allCompareRows = allRows;
28538
28539      // ── Stat chips ────────────────────────────────────────────────────────
28540      (function() {
28541        var projects = {}, latestTs = '', latestRow = null;
28542        allRows.forEach(function(r) {
28543          var p = r.dataset.project || ''; if (p) projects[p] = true;
28544          var ts = r.dataset.timestamp || '';
28545          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28546        });
28547        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();}
28548        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>':'');}
28549        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28550        if (latestRow) {
28551          setChipVal('agg-code', latestRow.dataset.code);
28552          setChipVal('agg-files', latestRow.dataset.files);
28553        }
28554        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(); });
28555      })();
28556
28557      // ── Branch filter population ──────────────────────────────────────────
28558      (function() {
28559        var branches = {};
28560        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28561        var sel = document.getElementById('branch-filter');
28562        if (sel) Object.keys(branches).sort().forEach(function(b) {
28563          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28564        });
28565      })();
28566
28567      // ── Filter ────────────────────────────────────────────────────────────
28568      function getFilteredRows() {
28569        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28570        var branch = ((document.getElementById('branch-filter') || {}).value || '');
28571        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28572          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28573          if (branch && (r.dataset.branch || '') !== branch) return false;
28574          return true;
28575        });
28576      }
28577
28578      // ── Pagination ────────────────────────────────────────────────────────
28579      function renderPage() {
28580        var filtered = getFilteredRows();
28581        var total = filtered.length;
28582        var totalPages = Math.max(1, Math.ceil(total / perPage));
28583        currentPage = Math.min(currentPage, totalPages);
28584        var start = (currentPage - 1) * perPage;
28585        var end = Math.min(start + perPage, total);
28586        var shown = {};
28587        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28588        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28589          r.style.display = shown[r.dataset.run] ? '' : 'none';
28590        });
28591        var rl = document.getElementById('page-range-label');
28592        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28593        var info = document.getElementById('pagination-info');
28594        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28595        var btns = document.getElementById('pagination-btns');
28596        if (!btns) return;
28597        btns.innerHTML = '';
28598        function makeBtn(lbl, pg, active, disabled) {
28599          var b = document.createElement('button');
28600          b.className = 'pg-btn' + (active ? ' active' : '');
28601          b.textContent = lbl; b.disabled = disabled;
28602          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28603          return b;
28604        }
28605        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28606        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28607        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28608        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28609      }
28610
28611      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28612      window.applyFilters = function() { currentPage = 1; renderPage(); };
28613
28614      // ── Sorting ───────────────────────────────────────────────────────────
28615      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28616      function doSort(col, type, order) {
28617        var tbody = document.getElementById('compare-tbody');
28618        if (!tbody) return;
28619        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28620        rows.sort(function(a, b) {
28621          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28622          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28623          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28624          return va < vb ? 1 : va > vb ? -1 : 0;
28625        });
28626        rows.forEach(function(r) { tbody.appendChild(r); });
28627        currentPage = 1; renderPage();
28628      }
28629      sortHeaders.forEach(function(th) {
28630        th.addEventListener('click', function(e) {
28631          if (e.target.classList.contains('col-resize-handle')) return;
28632          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28633          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28634          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28635          th.classList.add('sort-' + sortOrder);
28636          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28637          doSort(col, type, sortOrder);
28638        });
28639      });
28640
28641      // Apply default sort (timestamp desc) on initial load
28642      (function() {
28643        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28644        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28645      })();
28646
28647      // ── Column resize ─────────────────────────────────────────────────────
28648      (function() {
28649        var table = document.getElementById('compare-table');
28650        if (!table) return;
28651        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28652        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28653        ths.forEach(function(th, i) {
28654          var handle = th.querySelector('.col-resize-handle');
28655          if (!handle || !cols[i]) return;
28656          var startX, startW;
28657          handle.addEventListener('mousedown', function(e) {
28658            e.stopPropagation(); e.preventDefault();
28659            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28660            handle.classList.add('dragging');
28661            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28662            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28663            document.addEventListener('mousemove', onMove);
28664            document.addEventListener('mouseup', onUp);
28665          });
28666        });
28667      })();
28668
28669      // ── Full-commit hover tooltip ─────────────────────────────────────────
28670      // The commit chips live inside an overflow:auto table wrapper, which would
28671      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28672      // (escaping the scroll container) and follow the cursor. Event delegation
28673      // keeps it working after pagination/sorting re-renders the rows.
28674      (function() {
28675        var tip = document.createElement('div');
28676        tip.className = 'commit-tip';
28677        tip.setAttribute('role', 'tooltip');
28678        document.body.appendChild(tip);
28679        var shown = false;
28680        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28681        function place(e) {
28682          var pad = 14, r = tip.getBoundingClientRect();
28683          var x = e.clientX + pad, y = e.clientY + pad;
28684          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28685          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28686          tip.style.left = x + 'px'; tip.style.top = y + 'px';
28687        }
28688        function hide() { tip.style.display = 'none'; shown = false; }
28689        document.addEventListener('mouseover', function(e) {
28690          var chip = chipFrom(e.target);
28691          if (!chip) return;
28692          var full = chip.getAttribute('data-full-commit');
28693          if (!full) return;
28694          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28695        });
28696        document.addEventListener('mousemove', function(e) {
28697          if (!shown) return;
28698          if (chipFrom(e.target)) place(e); else hide();
28699        });
28700        document.addEventListener('mouseout', function(e) {
28701          if (chipFrom(e.target)) hide();
28702        });
28703      })();
28704
28705      // ── Reset view ────────────────────────────────────────────────────────
28706      window.resetView = function() {
28707        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28708        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28709        sortCol = null; sortOrder = 'asc';
28710        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28711        var tbody = document.getElementById('compare-tbody');
28712        if (tbody) {
28713          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28714          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28715          rows.forEach(function(r) { tbody.appendChild(r); });
28716        }
28717        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28718        var table = document.getElementById('compare-table');
28719        currentPage = 1; renderPage();
28720        currentPage = 1; renderPage();
28721      };
28722
28723      renderPage();
28724      buildCompareAllBar();
28725
28726      // ── Row selection state ───────────────────────────────────────────────
28727      var selected = [];
28728      var lockedProject = null; // project label of first selected scan
28729
28730      function updateCompareBtn() {
28731        var btn = document.getElementById('compare-btn');
28732        var cnt = document.getElementById('sel-count');
28733        if (!btn) return;
28734        btn.disabled = selected.length < 2;
28735        if (cnt) cnt.textContent = selected.length;
28736      }
28737
28738      function applyProjectLock() {
28739        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28740        allRows.forEach(function(r) {
28741          if (lockedProject === null) {
28742            r.classList.remove('row-locked');
28743          } else {
28744            var proj = r.dataset.project || '';
28745            if (proj !== lockedProject) {
28746              r.classList.add('row-locked');
28747            } else {
28748              r.classList.remove('row-locked');
28749            }
28750          }
28751        });
28752      }
28753
28754      function toggleRow(row) {
28755        if (row.classList.contains('row-locked')) return;
28756        var vid = row.dataset.vid || row.dataset.run;
28757        var idx = selected.indexOf(vid);
28758        if (idx >= 0) {
28759          selected.splice(idx, 1);
28760          row.classList.remove('selected');
28761          var b = document.getElementById('badge-' + vid);
28762          if (b) b.textContent = '';
28763          // Release project lock if nothing selected
28764          if (selected.length === 0) lockedProject = null;
28765        } else {
28766          // Set project lock on first selection
28767          if (selected.length === 0) lockedProject = row.dataset.project || null;
28768          selected.push(vid);
28769          row.classList.add('selected');
28770        }
28771        selected.forEach(function(v, i) {
28772          var b = document.getElementById('badge-' + v);
28773          if (b) b.textContent = i + 1;
28774        });
28775        applyProjectLock();
28776        updateCompareBtn();
28777        buildScopePanel();
28778      }
28779
28780      // ── Compare-All bar ───────────────────────────────────────────────────
28781      function buildCompareAllBar() {
28782        var bar = document.getElementById('compare-all-bar');
28783        if (!bar) return;
28784        // Group all rows by project label.
28785        var groups = {};
28786        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28787        // Use all rows from the source data (not just visible).
28788        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28789        // We need ALL rows across all pages, not just the rendered ones.
28790        // Use the underlying allRows array that the pagination JS also uses.
28791        var sourceRows = window._allCompareRows || allRowsAll;
28792        sourceRows.forEach(function(r) {
28793          var proj = r.dataset.project || '';
28794          var vid = r.dataset.vid || r.dataset.run || '';
28795          if (!proj || !vid) return;
28796          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28797          groups[proj].ids.push(vid);
28798          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28799        });
28800        // Build buttons for each project with >= 2 scans.
28801        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28802        if (!keys.length) { bar.style.display = 'none'; return; }
28803        bar.style.display = 'flex';
28804        // Remove old buttons (keep label).
28805        var oldBtns = bar.querySelectorAll('.compare-all-btn');
28806        oldBtns.forEach(function(b) { b.remove(); });
28807        keys.sort();
28808        keys.forEach(function(proj) {
28809          var g = groups[proj];
28810          var btn = document.createElement('button');
28811          btn.className = 'compare-all-btn';
28812          btn.type = 'button';
28813          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28814          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28815          btn.addEventListener('click', function() {
28816            // Sort ids by timestamp (ascending).
28817            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28818            pairs.sort(function(a, b) { return a.ts - b.ts; });
28819            var sorted = pairs.map(function(p) { return p.id; });
28820            if (sorted.length === 2) {
28821              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28822            } else {
28823              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28824            }
28825          });
28826          bar.appendChild(btn);
28827        });
28828      }
28829
28830      // ── Scope panel ───────────────────────────────────────────────────────
28831      var selectedScope = 'all';
28832
28833      function buildScopePanel() {
28834        var panel = document.getElementById('scope-panel');
28835        var opts = document.getElementById('scope-options');
28836        if (!panel || !opts) return;
28837        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28838
28839        // Collect union of submodules from all selected rows.
28840        var allSubs = {};
28841        selected.forEach(function(vid) {
28842          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28843          if (!row) return;
28844          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28845        });
28846        var subList = Object.keys(allSubs).sort();
28847        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28848
28849        panel.classList.remove('hidden');
28850        opts.innerHTML = '';
28851
28852        function makeOption(value, label, title) {
28853          var div = document.createElement('div');
28854          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28855          div.dataset.scopeValue = value;
28856          if (title) div.title = title;
28857          var radio = document.createElement('span');
28858          radio.className = 'scope-option-radio';
28859          var lbl = document.createElement('span');
28860          lbl.textContent = label;
28861          div.appendChild(radio);
28862          div.appendChild(lbl);
28863          div.addEventListener('click', function() {
28864            selectedScope = value;
28865            opts.querySelectorAll('.scope-option').forEach(function(o) {
28866              o.classList.toggle('selected', o.dataset.scopeValue === value);
28867            });
28868          });
28869          return div;
28870        }
28871
28872        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28873        var sep = document.createElement('span');
28874        sep.className = 'scope-option-sep';
28875        opts.appendChild(sep);
28876        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28877        subList.forEach(function(s) {
28878          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28879        });
28880      }
28881
28882      function doCompare() {
28883        if (selected.length < 2) return;
28884        if (selected.length === 2) {
28885          // Two-scan delta (existing flow with scope support).
28886          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28887          if (selectedScope === 'super') url += '&scope=super';
28888          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28889          window.location.href = url;
28890        } else {
28891          // Multi-scan timeline (N >= 3) — pass scope params too.
28892          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28893          if (selectedScope === 'super') url += '&scope=super';
28894          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28895          window.location.href = url;
28896        }
28897      }
28898
28899      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28900      var cbtn = document.getElementById('compare-btn');
28901      if (cbtn) cbtn.addEventListener('click', doCompare);
28902      var pfEl = document.getElementById('project-filter');
28903      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28904      var bfEl = document.getElementById('branch-filter');
28905      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28906      var rvBtn = document.getElementById('reset-view-btn');
28907      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28908      var ppSel = document.getElementById('per-page-sel');
28909      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28910
28911      var cmpTbody = document.getElementById('compare-tbody');
28912      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28913        var row = e.target.closest('.compare-row');
28914        if (row) toggleRow(row);
28915      });
28916
28917      (function randomizeWatermarks() {
28918        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28919        if (!wms.length) return;
28920        var placed = [];
28921        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;}
28922        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];}
28923        var half=Math.floor(wms.length/2);
28924        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;});
28925      })();
28926
28927      (function spawnCodeParticles() {
28928        var container = document.getElementById('code-particles');
28929        if (!container) return;
28930        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'];
28931        for (var i = 0; i < 38; i++) {
28932          (function(idx) {
28933            var el = document.createElement('span');
28934            el.className = 'code-particle';
28935            el.textContent = snippets[idx % snippets.length];
28936            var left = Math.random() * 94 + 2;
28937            var top = Math.random() * 88 + 6;
28938            var dur = (Math.random() * 10 + 9).toFixed(1);
28939            var delay = (Math.random() * 18).toFixed(1);
28940            var rot = (Math.random() * 26 - 13).toFixed(1);
28941            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28942            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';
28943            container.appendChild(el);
28944          })(i);
28945        }
28946      })();
28947
28948      // ── Watched folder picker ─────────────────────────────────────────────
28949      (function(){
28950        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');};
28951        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);
28952      })();
28953      (function() {
28954        var btn = document.getElementById('add-watched-btn');
28955        if (!btn) return;
28956        btn.addEventListener('click', function() {
28957          fetch('/pick-directory?kind=reports')
28958            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28959            .then(function(data) {
28960              if (!data.cancelled && data.selected_path) {
28961                var form = document.createElement('form');
28962                form.method = 'POST';
28963                form.action = '/watched-dirs/add';
28964                var ri = document.createElement('input');
28965                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28966                var fi = document.createElement('input');
28967                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28968                form.appendChild(ri); form.appendChild(fi);
28969                document.body.appendChild(form);
28970                if (window.__scanOverlay) window.__scanOverlay();
28971                form.submit();
28972              }
28973            })
28974            .catch(function(e) { alert('Could not open folder picker: ' + e); });
28975        });
28976      })();
28977
28978      // ── Submodule chip truncation ─────────────────────────────────────────
28979      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28980        var chips = cell.querySelectorAll('.submod-chip');
28981        var MAX = 4;
28982        if (chips.length <= MAX) return;
28983        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28984        var badge = document.createElement('span');
28985        badge.className = 'submod-overflow-badge';
28986        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28987        badge.textContent = '+' + (chips.length - MAX) + ' more';
28988        cell.appendChild(badge);
28989        cell.style.maxHeight = 'none';
28990      });
28991    })();
28992  </script>
28993  <script nonce="{{ csp_nonce }}">
28994  (function(){
28995    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'}];
28996    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);});}
28997    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28998    function init(){
28999      var btn=document.getElementById('settings-btn');if(!btn)return;
29000      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29001      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>';
29002      document.body.appendChild(m);
29003      var g=document.getElementById('scheme-grid');
29004      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);});
29005      var cl=document.getElementById('settings-close');
29006      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);});})();
29007      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');});
29008      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29009      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29010    }
29011    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29012  }());
29013  </script>
29014  <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]';
29015  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;}
29016  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>
29017</body>
29018</html>
29019"##,
29020    ext = "html"
29021)]
29022struct CompareSelectTemplate {
29023    version: &'static str,
29024    entries: Vec<HistoryEntryRow>,
29025    total_scans: usize,
29026    watched_dirs: Vec<String>,
29027    csp_nonce: String,
29028    server_mode: bool,
29029}
29030
29031// ── CompareTemplate ────────────────────────────────────────────────────────────
29032
29033#[derive(Template)]
29034#[template(
29035    source = r##"
29036<!doctype html>
29037<html lang="en">
29038<head>
29039  <meta charset="utf-8">
29040  <meta name="viewport" content="width=device-width, initial-scale=1">
29041  <title>OxideSLOC | Scan Delta</title>
29042  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29043  <style nonce="{{ csp_nonce }}">
29044    :root {
29045      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29046      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29047      --nav:#283790; --nav-2:#013e6b;
29048      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29049      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29050      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29051    }
29052    body.dark-theme {
29053      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29054      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29055    }
29056    *{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;}
29057    .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);}
29058    .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;}
29059    .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));}
29060    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29061    .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;}
29062    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29063    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29064    @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; } }
29065    .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;}
29066    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29067    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29068    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29069    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29070    .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;}
29071    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29072    .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);}
29073    .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;}
29074    .settings-close:hover{color:var(--text);background:var(--surface-2);}
29075    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29076    .settings-modal-body{padding:14px 16px 16px;}
29077    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29078    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29079    .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;}
29080    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29081    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29082    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29083    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29084    .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;}
29085    .tz-select:focus{border-color:var(--oxide);}
29086    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29087    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29088    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29089    .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;}
29090    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29091    .hero-body{display:block;}
29092    .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;}
29093    .btn-back:hover{background:var(--line);}
29094    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29095    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29096    .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;}
29097    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29098    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;}
29099    .muted{color:var(--muted);font-size:14px;}
29100    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29101    .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;}
29102    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29103    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29104    .vpill-arrow{font-size:20px;color:var(--muted);}
29105    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29106    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29107    .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;}
29108    .delta-card.delta-card-wide{padding:22px 24px;}
29109    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29110    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29111    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29112    .delta-card-from{font-size:15px;color:var(--muted);}
29113    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29114    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29115    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29116    .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%;}
29117    .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;}
29118    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29119    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29120    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29121    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29122    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29123    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29124    .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;}
29125    .meta-card-commit:hover{color:var(--oxide);}
29126    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29127    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29128    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29129    .meta-value{color:var(--text);font-size:13px;}
29130    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29131    .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;}
29132    .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);}
29133    .delta-card:hover .dc-tip{display:block;}
29134    .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;}
29135    .export-btn:hover{background:var(--line);}
29136    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29137    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29138    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29139    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29140    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29141    .delta-card-change.zero{color:var(--muted);background:transparent;}
29142    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29143    .delta-card-pct.pos{color:var(--pos);}
29144    .delta-card-pct.neg{color:var(--neg);}
29145    .delta-card-pct.zero{color:var(--muted);}
29146    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29147    .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;}
29148    .insight-card.insight-flag{border-color:var(--oxide);}
29149    .insight-card:hover .dc-tip{display:block;}
29150    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29151    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29152    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29153    .insight-label.flag{color:var(--oxide);}
29154    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29155    .insight-val.pos{color:var(--pos);}
29156    .insight-val.neg{color:var(--neg);}
29157    .insight-val.high{color:#c0392a;}
29158    .insight-val.med{color:#926000;}
29159    .insight-val.low{color:var(--pos);}
29160    body.dark-theme .insight-val.high{color:#ff6b6b;}
29161    body.dark-theme .insight-val.med{color:#f0c060;}
29162    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29163    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29164    .fc-row{display:flex;align-items:center;gap:8px;}
29165    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29166    .fc-label{color:var(--muted);}
29167    .fc-modified .fc-count{color:#926000;}
29168    .fc-added .fc-count{color:var(--pos);}
29169    .fc-removed .fc-count{color:var(--neg);}
29170    .fc-unchanged .fc-count{color:var(--muted);}
29171    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29172    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29173    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29174    .chip.modified{background:#fff2d8;color:#926000;}
29175    .chip.added{background:#e8f5ed;color:#1a8f47;}
29176    .chip.removed{background:#fdeaea;color:#b33b3b;}
29177    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29178    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29179    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29180    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29181    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29182    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29183    .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;}
29184    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29185    .tab-btn:hover:not(.active){background:var(--line);}
29186    .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;}
29187    .btn-reset:hover{background:var(--line);}
29188    .table-wrap{width:100%;overflow-x:auto;}
29189    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29190    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);}
29191    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29192    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29193    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29194    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29195    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29196    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29197    tr:last-child td{border-bottom:none;}
29198    tr:hover td{background:var(--surface-2);}
29199    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29200    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29201    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29202    /* Fixed layout: column widths come from the colgroup, not from scanning every
29203       row. With auto layout a large file matrix forces the browser to re-measure
29204       all cells on each reflow, which freezes the page during sort/resize. */
29205    #delta-table{table-layout:fixed;}
29206    #delta-table col:nth-child(1){width:32%;}
29207    #delta-table col:nth-child(2){width:11%;}
29208    #delta-table col:nth-child(3){width:11%;}
29209    #delta-table col:nth-child(4){width:16%;}
29210    #delta-table col:nth-child(5){width:10%;}
29211    #delta-table col:nth-child(6){width:10%;}
29212    #delta-table col:nth-child(7){width:10%;}
29213    tr.row-added td{background:rgba(26,143,71,0.04);}
29214    tr.row-removed td{background:rgba(179,59,59,0.06);}
29215    tr.row-modified td{background:rgba(146,96,0,0.04);}
29216    tr.row-unchanged td{color:var(--muted);}
29217    tr.row-unchanged .status-badge{opacity:.65;}
29218    .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;}
29219    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29220    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29221    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29222    .status-badge.modified{background:#fff2d8;color:#926000;}
29223    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29224    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29225    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29226    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29227    .delta-val{font-weight:700;}
29228    .delta-val.pos{color:var(--pos);}
29229    .delta-val.neg{color:var(--neg);}
29230    .delta-val.zero{color:var(--muted);}
29231    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29232    .from-to strong{color:var(--text);font-weight:700;}
29233    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29234    .from-to .ft-absent{color:var(--muted);font-weight:600;}
29235    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29236    .site-footer a{color:var(--muted);}
29237    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;}
29238    body.pdf-mode{background:#fff!important;}
29239    body.pdf-mode .page{padding:4px 6px 4px!important;}
29240    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29241    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29242    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29243    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29244    .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;}
29245    .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;}
29246    .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;}
29247    @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));}}
29248    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29249    .path-link:hover{color:var(--oxide-2);}
29250    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29251    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29252    a.vpill-id:hover{color:var(--oxide);}
29253    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29254    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29255    .pagination-info{font-size:13px;color:var(--muted);}
29256    .pagination-btns{display:flex;gap:6px;}
29257    .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;}
29258    .pg-btn:hover:not(:disabled){background:var(--line);}
29259    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29260    .pg-btn:disabled{opacity:.35;cursor:default;}
29261    .per-page-label{font-size:13px;color:var(--muted);}
29262    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;}
29263    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29264    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29265    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29266    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29267    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29268    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29269    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29270    .tab-btn.tab-unchanged{color:var(--muted);}
29271    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29272    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29273    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29274    .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;}
29275    .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;}
29276    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29277    .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;}
29278    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29279    .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;}
29280    .submod-scope-btn:hover{background:var(--line);}
29281    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29282    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29283    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29284    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29285    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29286    body.dark-theme .ic-card{background:var(--surface-2);}
29287    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29288    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29289    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29290    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29291    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29292    .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);}
29293    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29294    .ic-card-h2-row .ic-card-h2{margin:0;}
29295    .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;}
29296    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29297    .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;}
29298    .ic-svg-modal-ov.open{display:flex;}
29299    .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);}
29300    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29301    .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);}
29302    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29303    .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;}
29304    .ic-svg-modal-close:hover{background:var(--line);}
29305    .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;}
29306    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29307    .chart-metric-btn:hover:not(.active){background:var(--line);}
29308    .chart-wrap{width:100%;overflow-x:auto;}
29309    #cmp-tl-svg{display:block;width:100%;}
29310    .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);}
29311    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29312    #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;}
29313  </style>
29314</head>
29315<body>
29316  {{ loading_overlay|safe }}
29317  <div class="background-watermarks" aria-hidden="true">
29318    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29319    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29320    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29321    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29322    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29323    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29324  </div>
29325  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29326  <div class="top-nav">
29327    <div class="top-nav-inner">
29328      <a class="brand" href="/">
29329        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29330        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29331      </a>
29332      <div class="nav-right">
29333        <a class="nav-pill" href="/">Home</a>
29334        <div class="nav-dropdown">
29335          <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>
29336          <div class="nav-dropdown-menu">
29337            <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>
29338          </div>
29339        </div>
29340        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29341        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29342        <div class="nav-dropdown">
29343          <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>
29344          <div class="nav-dropdown-menu">
29345            <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>
29346          </div>
29347        </div>
29348        <div class="server-status-wrap" id="server-status-wrap">
29349          <div class="nav-pill server-online-pill" id="server-status-pill">
29350            <span class="status-dot" id="status-dot"></span>
29351            <span id="server-status-label">Server</span>
29352            <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29353          </div>
29354          <div class="server-status-tip">
29355            OxideSLOC is running — accessible on your network.
29356            <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29357          </div>
29358        </div>
29359        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29360          <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>
29361        </button>
29362        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29363          <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>
29364          <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>
29365        </button>
29366      </div>
29367    </div>
29368  </div>
29369
29370  <div class="page">
29371    <section class="hero">
29372      <div class="hero-header">
29373        <div>
29374          <h1 class="delta-title">Scan Delta</h1>
29375          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29376          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29377            {% if let Some(sub) = active_submodule %}
29378            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29379            {% else if super_scope_active %}
29380            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29381            {% else %}
29382            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29383            {% endif %}
29384            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29385          </div>
29386        </div>
29387        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29388          <a class="btn-back" href="/compare-scans">
29389            <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>
29390            Compare Scans
29391          </a>
29392          <div class="export-group" style="margin-top:12px;">
29393            <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>
29394            <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>
29395          </div>
29396        </div>
29397      </div>
29398      {% if has_any_submodule_data %}
29399      <div class="submod-scope-bar">
29400        <span class="submod-scope-label">
29401          <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>
29402          Scope:
29403        </span>
29404        <div class="submod-scope-divider"></div>
29405        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29406           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
29407           title="All files — super-repo and all submodules combined">Full scan</a>
29408        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29409           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
29410           title="Only files that are not part of any submodule">Super-repo only</a>
29411        {% for sub in submodule_options %}
29412        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29413           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
29414           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29415        {% endfor %}
29416      </div>
29417      {% endif %}
29418      <div class="hero-body">
29419      <div class="meta-strip">
29420        <div class="delta-card delta-card-meta">
29421          <div class="meta-card-header">
29422            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29423            <div class="meta-card-project-col">
29424              <div class="meta-card-project">{{ project_name }}</div>
29425              {% if has_any_submodule_data %}
29426              {% if let Some(sub) = active_submodule %}
29427              <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>
29428              {% else if super_scope_active %}
29429              <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>
29430              {% else %}
29431              <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>
29432              {% endif %}
29433              {% endif %}
29434            </div>
29435          </div>
29436          {% if !baseline_git_commit.is_empty() %}
29437          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29438          {% else %}
29439          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29440          {% endif %}
29441          <div class="meta-card-rows">
29442            <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>
29443            <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>
29444            <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>
29445            <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>
29446            {% if let Some(tags) = baseline_git_tags %}
29447            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29448            {% endif %}
29449          </div>
29450        </div>
29451        <div class="delta-card delta-card-meta">
29452          <div class="meta-card-header">
29453            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29454            <div class="meta-card-project-col">
29455              <div class="meta-card-project">{{ project_name }}</div>
29456              {% if has_any_submodule_data %}
29457              {% if let Some(sub) = active_submodule %}
29458              <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>
29459              {% else if super_scope_active %}
29460              <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>
29461              {% else %}
29462              <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>
29463              {% endif %}
29464              {% endif %}
29465            </div>
29466          </div>
29467          {% if !current_git_commit.is_empty() %}
29468          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29469          {% else %}
29470          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29471          {% endif %}
29472          <div class="meta-card-rows">
29473            <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>
29474            <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>
29475            <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>
29476            <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>
29477            {% if let Some(tags) = current_git_tags %}
29478            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29479            {% endif %}
29480          </div>
29481        </div>
29482      </div>
29483      <div class="delta-strip">
29484        <div class="delta-card">
29485          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29486          <div class="delta-card-label">Code lines</div>
29487          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29488          <div class="delta-card-to">{{ current_code_fmt }}</div>
29489          {% 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>
29490          {% 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>
29491          {% else %}<div class="delta-card-pct zero">±0%</div>
29492          {% endif %}
29493        </div>
29494        <div class="delta-card">
29495          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29496          <div class="delta-card-label">Files analyzed</div>
29497          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29498          <div class="delta-card-to">{{ current_files_fmt }}</div>
29499          {% 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>
29500          {% 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>
29501          {% else %}<div class="delta-card-pct zero">±0%</div>
29502          {% endif %}
29503        </div>
29504        <div class="delta-card">
29505          <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>
29506          <div class="delta-card-label">Comment lines</div>
29507          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29508          <div class="delta-card-to">{{ current_comments_fmt }}</div>
29509          {% 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>
29510          {% 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>
29511          {% else %}<div class="delta-card-pct zero">±0%</div>
29512          {% endif %}
29513        </div>
29514        {{ coverage_delta_card|safe }}
29515        <div class="delta-card delta-card-wide">
29516          <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>
29517          <div class="delta-card-label">File changes</div>
29518          <div class="file-changes-grid">
29519            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29520            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29521            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29522            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29523          </div>
29524        </div>
29525      </div>
29526      <div class="insights-panel">
29527        <div class="insight-card">
29528          <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>
29529          <div class="insight-label">Lines Added</div>
29530          <div class="insight-val pos">+{{ code_lines_added }}</div>
29531          <div class="insight-sub">New or grown source lines</div>
29532        </div>
29533        <div class="insight-card">
29534          <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>
29535          <div class="insight-label">Lines Removed</div>
29536          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
29537          <div class="insight-sub">Deleted or shrunk source lines</div>
29538        </div>
29539        <div class="insight-card">
29540          <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>
29541          <div class="insight-label">Lines Modified</div>
29542          <div class="insight-val">{{ code_lines_modified }}</div>
29543          <div class="insight-sub">Code lines in modified files</div>
29544        </div>
29545        <div class="insight-card">
29546          <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>
29547          <div class="insight-label">Lines Unmodified</div>
29548          <div class="insight-val">{{ code_lines_unmodified }}</div>
29549          <div class="insight-sub">Code lines in unchanged files</div>
29550        </div>
29551        <div class="insight-card">
29552          <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>
29553          <div class="insight-label">Churn Rate</div>
29554          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29555          <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>
29556        </div>
29557        {% if scope_flag %}
29558        <div class="insight-card insight-flag">
29559          <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>
29560          <div class="insight-label flag">Scope Signal</div>
29561          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29562          <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>
29563        </div>
29564        {% endif %}
29565      </div>
29566      </div>
29567    </section>
29568
29569    <section class="panel" id="inline-charts-section">
29570      <div class="panel-title">Scan Delta Charts</div>
29571      <div class="ic-grid">
29572        <div class="ic-card" style="grid-column:span 2">
29573          <div class="ic-card-h2-row">
29574            <span class="ic-card-h2">Timeline</span>
29575            <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29576              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29577              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29578              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29579              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29580              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29581            </div>
29582            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
29583          </div>
29584          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29585        </div>
29586        <div class="ic-card">
29587          <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>
29588          <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>
29589          <div id="ic-c1"></div>
29590        </div>
29591        <div class="ic-card" id="ic-lang-card">
29592          <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>
29593          <div id="ic-c3"></div>
29594        </div>
29595        <div class="ic-card">
29596          <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>
29597          <div id="ic-c2"></div>
29598        </div>
29599        <div class="ic-card">
29600          <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>
29601          <div id="ic-c4"></div>
29602        </div>
29603      </div>
29604      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29605        <div class="ic-svg-modal">
29606          <div class="ic-svg-modal-hdr">
29607            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29608            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
29609          </div>
29610          <div id="ic-svg-modal-body"></div>
29611        </div>
29612      </div>
29613    </section>
29614
29615    <section class="panel">
29616      <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>
29617      <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29618        <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29619          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29620          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29621          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29622          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29623          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29624        </div>
29625        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29626          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
29627          <div class="export-group">
29628            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
29629            <button type="button" class="export-btn" id="delta-csv-btn">
29630              <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>
29631              CSV
29632            </button>
29633            <button type="button" class="export-btn" id="delta-xls-btn">
29634              <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>
29635              Excel
29636            </button>
29637          </div>
29638        </div>
29639      </div>
29640
29641      <div class="table-wrap">
29642      <table id="delta-table">
29643        <colgroup>
29644          <col>
29645          <col>
29646          <col>
29647          <col>
29648          <col>
29649          <col>
29650          <col>
29651        </colgroup>
29652        <thead>
29653          <tr id="delta-thead">
29654            <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>
29655            <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>
29656            <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>
29657            <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>
29658            <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>
29659            <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>
29660            <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>
29661          </tr>
29662        </thead>
29663        <tbody id="delta-tbody">
29664          {% for row in file_rows %}
29665          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29666              data-path="{{ row.relative_path }}"
29667              data-language="{{ row.language }}"
29668              data-baseline-code="{{ row.baseline_code }}"
29669              data-current-code="{{ row.current_code }}"
29670              data-code-delta="{{ row.code_delta_str }}"
29671              data-comment-delta="{{ row.comment_delta_str }}"
29672              data-total-delta="{{ row.total_delta_str }}"
29673              data-orig-idx="">
29674            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29675            <td class="hide-sm">{{ row.language }}</td>
29676            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29677            <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>
29678            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29679            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29680            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29681          </tr>
29682          {% endfor %}
29683        </tbody>
29684      </table>
29685      </div>
29686      <div class="pagination">
29687        <span class="pagination-info" id="pg-range-label"></span>
29688        <div class="pagination-btns" id="pg-btns"></div>
29689        <div class="flex-row">
29690          <span class="per-page-label">Show</span>
29691          <select class="per-page" id="per-page-sel">
29692            <option value="10">10 per page</option>
29693            <option value="25" selected>25 per page</option>
29694            <option value="50">50 per page</option>
29695            <option value="100">100 per page</option>
29696          </select>
29697        </div>
29698      </div>
29699    </section>
29700  </div>
29701
29702  <div id="ic-tt"></div>
29703
29704  <footer class="site-footer">
29705    local code analysis - metrics, history and reports
29706    &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>
29707    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29708    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29709    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29710    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29711  </footer>
29712
29713  <script nonce="{{ csp_nonce }}">
29714    (function () {
29715      var storageKey = 'oxide-sloc-theme';
29716      var body = document.body;
29717      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29718      var toggle = document.getElementById('theme-toggle');
29719      if (toggle) toggle.addEventListener('click', function () {
29720        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29721        body.classList.toggle('dark-theme', next === 'dark');
29722        try { localStorage.setItem(storageKey, next); } catch(e) {}
29723      });
29724
29725      (function randomizeWatermarks() {
29726        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29727        if (!wms.length) return;
29728        var placed = [];
29729        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;}
29730        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];}
29731        var half=Math.floor(wms.length/2);
29732        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;});
29733      })();
29734
29735      (function spawnCodeParticles() {
29736        var container = document.getElementById('code-particles');
29737        if (!container) return;
29738        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'];
29739        for (var i = 0; i < 38; i++) {
29740          (function(idx) {
29741            var el = document.createElement('span');
29742            el.className = 'code-particle';
29743            el.textContent = snippets[idx % snippets.length];
29744            var left = Math.random() * 94 + 2;
29745            var top = Math.random() * 88 + 6;
29746            var dur = (Math.random() * 10 + 9).toFixed(1);
29747            var delay = (Math.random() * 18).toFixed(1);
29748            var rot = (Math.random() * 26 - 13).toFixed(1);
29749            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29750            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';
29751            container.appendChild(el);
29752          })(i);
29753        }
29754      })();
29755    })();
29756
29757    var activeStatusFilter = 'all';
29758    var deltaPerPage = 25, deltaCurrPage = 1;
29759
29760    function openFolder(path) {
29761      fetch('/open-path?path=' + encodeURIComponent(path))
29762        .then(function (r) { return r.json(); })
29763        .then(function (d) {
29764          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29765        })
29766        .catch(function () {});
29767    }
29768
29769    // \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
29770    // The server renders every row once; we lift them into a plain-data array and
29771    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29772    // filtering run on the array (no DOM churn) and each render rebuilds just one
29773    // page (~25 rows). This keeps every interaction O(page) instead of O(all
29774    // files): a 28k-row table previously re-touched every node on each click
29775    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29776    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29777
29778    function parseDeltaNum(str) {
29779      if (!str || str === '\u2014') return 0;
29780      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29781    }
29782
29783    function captureDelta() {
29784      var tbody = document.getElementById('delta-tbody');
29785      if (!tbody) return;
29786      var rows = tbody.querySelectorAll('.delta-row');
29787      for (var i = 0; i < rows.length; i++) {
29788        var r = rows[i];
29789        DELTA.push({
29790          h: r.innerHTML,
29791          cls: r.className,
29792          path: r.getAttribute('data-path') || '',
29793          lang: r.getAttribute('data-language') || '',
29794          status: r.getAttribute('data-status') || '',
29795          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29796          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29797          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29798          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29799          td: parseDeltaNum(r.getAttribute('data-total-delta')),
29800          bcs: r.getAttribute('data-baseline-code') || '',
29801          ccs: r.getAttribute('data-current-code') || '',
29802          cds: r.getAttribute('data-code-delta') || '',
29803          cmds: r.getAttribute('data-comment-delta') || '',
29804          tds: r.getAttribute('data-total-delta') || ''
29805        });
29806      }
29807      tbody.innerHTML = '';
29808    }
29809
29810    function applyDeltaQuery() {
29811      var v = (activeStatusFilter === 'all') ? DELTA.slice()
29812        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29813      if (sortCol) {
29814        var asc = sortOrder === 'asc';
29815        v.sort(function(a, b) {
29816          var va, vb;
29817          if (sortCol === 'path') { va = a.path; vb = b.path; }
29818          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29819          else if (sortCol === 'status') { va = a.status; vb = b.status; }
29820          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29821          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29822          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29823          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29824          else { return 0; }
29825          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29826          return va < vb ? 1 : va > vb ? -1 : 0;
29827        });
29828      }
29829      _deltaView = v;
29830      deltaCurrPage = 1;
29831      renderDeltaPage();
29832    }
29833
29834    function renderDeltaPage() {
29835      var total = _deltaView.length;
29836      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29837      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29838      if (deltaCurrPage < 1) deltaCurrPage = 1;
29839      var start = (deltaCurrPage - 1) * deltaPerPage;
29840      var end = Math.min(start + deltaPerPage, total);
29841      var tbody = document.getElementById('delta-tbody');
29842      if (tbody) {
29843        var html = '';
29844        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29845        tbody.innerHTML = html;
29846      }
29847      var rl = document.getElementById('pg-range-label');
29848      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29849      var btns = document.getElementById('pg-btns');
29850      if (!btns) return;
29851      btns.innerHTML = '';
29852      if (totalPages <= 1) return;
29853      function makeBtn(lbl, pg, active, disabled) {
29854        var b = document.createElement('button');
29855        b.className = 'pg-btn' + (active ? ' active' : '');
29856        b.textContent = lbl; b.disabled = disabled;
29857        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29858        return b;
29859      }
29860      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29861      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29862      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29863      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29864    }
29865
29866    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29867
29868    function filterRows(status, btn) {
29869      activeStatusFilter = status;
29870      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29871        b.classList.remove('active');
29872      });
29873      if (btn) btn.classList.add('active');
29874      applyDeltaQuery();
29875    }
29876
29877    // ── Sorting ──────────────────────────────────────────────────────────────
29878    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29879    sortHeaders.forEach(function(th) {
29880      th.addEventListener('click', function(e) {
29881        if (e.target.classList.contains('col-resize-handle')) return;
29882        var col = th.dataset.sortCol;
29883        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29884        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29885        th.classList.add('sort-' + sortOrder);
29886        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29887        applyDeltaQuery();
29888      });
29889    });
29890
29891    // ── Column resize ─────────────────────────────────────────────────────────
29892    (function() {
29893      var table = document.getElementById('delta-table');
29894      if (!table) return;
29895      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29896      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29897      ths.forEach(function(th, i) {
29898        var handle = th.querySelector('.col-resize-handle');
29899        if (!handle || !cols[i]) return;
29900        handle.addEventListener('mousedown', function(e) {
29901          e.stopPropagation(); e.preventDefault();
29902          // Lock every column to its current rendered px width and size the table
29903          // to the column total. With table-layout:fixed + width:100% the table is
29904          // pinned to the container, so widening one <col> only rebalances the rest
29905          // and the drag looks inert; pinning px widths lets the column actually
29906          // grow while the wrapper (overflow-x:auto) scrolls.
29907          var startTableW = 0;
29908          for (var k = 0; k < ths.length; k++) {
29909            if (!cols[k]) continue;
29910            var w = ths[k].getBoundingClientRect().width;
29911            cols[k].style.width = w + 'px';
29912            startTableW += w;
29913          }
29914          table.style.width = startTableW + 'px';
29915          var startX = e.clientX;
29916          var startW = ths[i].getBoundingClientRect().width;
29917          handle.classList.add('dragging');
29918          function onMove(ev) {
29919            var newW = Math.max(40, startW + ev.clientX - startX);
29920            cols[i].style.width = newW + 'px';
29921            table.style.width = (startTableW + (newW - startW)) + 'px';
29922          }
29923          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29924          document.addEventListener('mousemove', onMove);
29925          document.addEventListener('mouseup', onUp);
29926        });
29927      });
29928    })();
29929
29930    // ── Reset ─────────────────────────────────────────────────────────────────
29931    window.resetDeltaTable = function() {
29932      sortCol = null; sortOrder = 'asc';
29933      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29934      var table = document.getElementById('delta-table');
29935      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29936      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29937      activeStatusFilter = 'all';
29938      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29939      var allBtn = document.querySelector('.tab-btn');
29940      if (allBtn) allBtn.classList.add('active');
29941      applyDeltaQuery();
29942    };
29943
29944    // Compact number formatter (shared by the delta table; charts define their own locally)
29945    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();}
29946    function fmtFull(n){return Number(n).toLocaleString();}
29947
29948    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29949    function fmtFromTo() {
29950      var tbody = document.getElementById('delta-tbody');
29951      if (!tbody) return;
29952      tbody.querySelectorAll('.delta-row').forEach(function(row) {
29953        var status = row.dataset.status || '';
29954        var ft = row.querySelector('.from-to');
29955        if (!ft) return;
29956        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29957        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29958        var strongs = ft.querySelectorAll('strong');
29959        // Apply fmt() to non-absent strong values
29960        strongs.forEach(function(el) {
29961          var n = parseInt(el.textContent, 10);
29962          if (!isNaN(n)) el.textContent = fmtFull(n);
29963        });
29964        // Safety: force dash for genuinely absent sides
29965        if (status === 'added' && bv === 0) {
29966          var bs = ft.querySelector('strong:first-of-type');
29967          if (bs && bs.textContent === '0') {
29968            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29969          }
29970        }
29971        if (status === 'removed' && cv === 0) {
29972          var cs = ft.querySelector('strong:last-of-type');
29973          if (cs && cs.textContent === '0') {
29974            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29975          }
29976        }
29977      });
29978    }
29979    // Initialize: format the server-rendered rows, lift them into the data model
29980    // (which also clears the DOM), then render only the first page.
29981    fmtFromTo();
29982    captureDelta();
29983    applyDeltaQuery();
29984
29985    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29986    (function() {
29987      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29988        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29989      });
29990      var resetBtn = document.getElementById('delta-reset-btn');
29991      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29992      var csvBtn = document.getElementById('delta-csv-btn');
29993      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29994      var xlsBtn = document.getElementById('delta-xls-btn');
29995      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29996      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29997      function sdFetchUri(path) {
29998        return fetch(path).then(function(r){return r.blob();}).then(function(b){
29999          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30000        }).catch(function(){return '';});
30001      }
30002      function sdInlineImgs(html, cb) {
30003        var paths=[], seen={};
30004        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30005        if(!paths.length){cb(html);return;}
30006        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30007          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30008          .catch(function(){cb(html);});
30009      }
30010      function buildFullPageHtml(pdfMode) {
30011        if(pdfMode) document.body.classList.add('pdf-mode');
30012        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30013        renderDeltaPage();
30014        var html = document.documentElement.outerHTML;
30015        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30016        if(pdfMode) document.body.classList.remove('pdf-mode');
30017        return html;
30018      }
30019      var chartsBtn = document.getElementById('delta-charts-btn');
30020      if (chartsBtn) chartsBtn.addEventListener('click', function() {
30021        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30022        sdInlineImgs(buildFullPageHtml(false), function(html) {
30023          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30024          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30025          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30026          btn.disabled=false;btn.innerHTML=orig;
30027        });
30028      });
30029      var pageHtmlBtn = document.getElementById('page-export-html-btn');
30030      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30031        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30032        sdInlineImgs(buildFullPageHtml(false), function(html) {
30033          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30034          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30035          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30036          btn.disabled=false;btn.innerHTML=orig;
30037        });
30038      });
30039      // PDF export — clean document-style report, not a web page screenshot
30040      function buildDeltaPdfHtml() {
30041        var sd=_sd, dr=getDeltaExportRows();
30042        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30043        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+'%';}
30044        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30045        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30046        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30047        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30048        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30049        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30050        function fmtN(n){return Number(n).toLocaleString();}
30051        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30052        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>';}
30053        var lm={};
30054        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;});
30055        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30056        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30057        // The header/footer flow in normal document order (NOT position:fixed).
30058        // A fixed header repeats on every printed page in Chromium and overlaps
30059        // the content beneath it — silently swallowing the first few table rows of
30060        // pages 2+ and clipping the summary cards on page 1. Letting the header
30061        // flow once at the top and relying on the table's <thead> (which Chromium
30062        // repeats per page) keeps every row visible. `.body` keeps a small inset
30063        // so nothing bleeds to the sheet edge.
30064        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30065          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30066          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30067          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30068          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30069          '.ph-brand em{color:#c45c10;font-style:normal;}'+
30070          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30071          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30072          '.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;}'+
30073          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30074          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30075          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30076          '.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;}'+
30077          '.body{padding:12px 18px 0;}'+
30078          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30079          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30080          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30081          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30082          '.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;}'+
30083          '.meta>div{flex:1 1 0;}'+
30084          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30085          '.sec{margin-bottom:10px;}'+
30086          '.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;}'+
30087          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30088          '.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;}'+
30089          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30090          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30091          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30092          '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;}'+
30093          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30094          'tr:nth-child(even) td{background:#faf8f6;}'+
30095          '.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;}'+
30096          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30097          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30098          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30099          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30100          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30101          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30102          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30103          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30104          '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30105          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30106          '.fcc-n{font-size:18px;font-weight:900;}'+
30107          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30108        var fileRows=dchg.map(function(r){
30109          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30110          return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30111            '<td style="'+ss+'">'+esc(st)+'</td>'+
30112            '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30113            '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30114            '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30115        }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30116        var more='';
30117        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('');
30118        var extraCards='';
30119        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>';}
30120        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>';}
30121        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30122          '<div class="pdf-header">'+
30123          '<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>'+
30124          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30125          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30126          '</div>'+
30127          '<div class="body">'+
30128          '<div class="sec"><p class="sh">Summary Metrics</p>'+
30129          '<div class="msec">'+
30130          '<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>'+
30131          '<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>'+
30132          '<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>'+
30133          '<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>'+
30134          '<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>'+
30135          '<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>'+
30136          extraCards+'</div></div>'+
30137          '<div class="sec"><p class="sh">File Changes</p>'+
30138          '<div class="fcsec">'+
30139          '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30140          '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30141          '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30142          '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30143          '</div></div>'+
30144          (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>':'')+
30145          '<div class="sec">'+
30146          '<table><thead>'+
30147          '<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>'+
30148          '<tr><th>File</th><th>Language</th><th>Status</th>'+
30149          '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30150          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30151          '</div>'+
30152          '<div class="rfoot">'+
30153          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30154          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30155          '</div>'+
30156          '</body></html>';
30157      }
30158      function doDeltaPdf(btn) {
30159        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30160      }
30161      var pdfBtn = document.getElementById('delta-pdf-btn');
30162      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30163      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30164      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30165      if (location.protocol === 'file:') {
30166        [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'; } });
30167        [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'; } });
30168      }
30169      var ppSel = document.getElementById('per-page-sel');
30170      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30171      var pathLink = document.getElementById('project-path-link');
30172      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30173    })();
30174
30175    // ── Export helpers ────────────────────────────────────────────────────────
30176    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
30177    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30178    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);}
30179    function slocMakeXlsx(fname,sd,dr){
30180      var enc=new TextEncoder();
30181      // CRC-32 table
30182      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;}
30183      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;}
30184      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30185      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30186      // Shared string table
30187      var ss=[],si={};
30188      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30189      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30190      // Worksheet builder — each WS() call gets its own row counter R
30191      function WS(){
30192        var R=0,buf=[];
30193        function cl(c){return String.fromCharCode(65+c);}
30194        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30195          '<v>'+S(v)+'</v></c>';}
30196        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30197          (st?' s="'+st+'"':'')+'>'+
30198          '<v>'+(+v)+'</v></c>';}
30199        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30200        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30201          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30202          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30203          '<sheetFormatPr defaultRowHeight="15"/>'+
30204          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30205        return{sc:sc,nc:nc,row:row,xml:xml};
30206      }
30207      // Language breakdown
30208      var lm={};
30209      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;});
30210      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30211      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30212      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30213      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30214      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30215      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30216      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):'';}
30217      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30218      // Summary sheet
30219      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30220      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30221      r1(s1(0,proj,2));
30222      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30223      r1('');
30224      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30225      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))));
30226      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))));
30227      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))));
30228      r1('');
30229      r1(s1(0,'FILE CHANGES',8));
30230      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30231      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30232      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30233      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30234      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30235      if(langs.length){
30236        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30237        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30238        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)));});
30239      }
30240      r1('');r1(s1(0,'SCAN METADATA',8));
30241      r1(s1(1,_blabel)+s1(2,_clabel));
30242      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30243      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30244      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"/>');
30245      // File Delta sheet
30246      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30247      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));
30248      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)));});
30249      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30250      // Shared strings XML
30251      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30252        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30253        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30254      // XLSX file map
30255      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30256      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>',
30257        '_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>',
30258        '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>',
30259        '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>',
30260        '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>',
30261        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30262      // ZIP packer — STORED (no compression), compatible with all XLSX readers
30263      var zparts=[],zcds=[],zoff=0,znf=0;
30264      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30265       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30266      ].forEach(function(name){
30267        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30268        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]);
30269        var entry=new Uint8Array(lha.length+nb.length+sz);
30270        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30271        zparts.push(entry);
30272        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));
30273        var cde=new Uint8Array(cda.length+nb.length);
30274        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30275        zcds.push(cde);zoff+=entry.length;znf++;
30276      });
30277      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30278      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]);
30279      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30280      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30281      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30282      zout.set(new Uint8Array(ea),zpos);
30283      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30284      var xurl=URL.createObjectURL(xblob);
30285      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30286      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30287      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30288    }
30289    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;');}
30290    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30291    function getExportFilename(ext){return _exportBase+'.'+ext;}
30292
30293    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 }}'};
30294    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;}
30295    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30296    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30297    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30298    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30299    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):'';}
30300    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30301    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)]];}
30302    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30303    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)];});}
30304    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30305    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30306
30307    // ── Chart HTML report ─────────────────────────────────────────────────────
30308    function slocChartReport(fname, sd, dr) {
30309      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30310      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30311      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30312      function fmt(n){return Number(n).toLocaleString();}
30313      function px(n){return Math.round(n);}
30314      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30315      // Language map
30316      var lm={};
30317      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;});
30318      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30319
30320      // Builds onmouse* attrs for interactive tooltip on each SVG element
30321      function barTT(label,val){
30322        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30323      }
30324
30325      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30326      //    match the Language Code Delta column height) ────────────
30327      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'}];
30328      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30329      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30330      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30331      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30332      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"/>';}
30333      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30334      c1mets.forEach(function(m,i){
30335        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30336        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30337        var gMax=Math.max(m.b,m.c)*1.15||1;
30338        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30339        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>';
30340        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))+'/>';
30341        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>';
30342        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))+'/>';
30343        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>';
30344        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>';
30345        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>';
30346      });
30347      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>';
30348      c1+='</svg>';
30349
30350      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30351      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'}];
30352      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30353      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30354      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30355      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30356      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30357      mets.forEach(function(m,i){
30358        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30359        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30360        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30361        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>';
30362        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30363        if(bw>=52){
30364          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>';
30365        }else{
30366          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30367          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>';
30368        }
30369      });
30370      c2+='</svg>';
30371
30372      // ── Chart 3: Language Code Delta ─────────────────────────────────────
30373      var c3='';
30374      if(langs.length){
30375        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30376        var C3W=550,c3LW=124,c3FW=52;
30377        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30378        var L3rH=30,C3H=langs.length*L3rH+20;
30379        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30380        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30381        langs.forEach(function(l,i){
30382          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30383          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30384          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30385          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>';
30386          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':''))+'/>';
30387          if(bw>=48){
30388            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>';
30389          }else{
30390            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30391            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>';
30392          }
30393          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>';
30394        });
30395        c3+='</svg>';
30396      }
30397
30398      // ── Chart 4: File Change Donut — centered pie with legend below
30399      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;});
30400      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30401      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30402      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">';
30403      var ang=-Math.PI/2;
30404      segs.forEach(function(s){
30405        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30406        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30407        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30408        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30409        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30410        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)+'%')+'/>';
30411        ang+=sw;
30412      });
30413      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>';
30414      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>';
30415      segs.forEach(function(s,i){
30416        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30417        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30418        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>';
30419      });
30420      c4+='</svg>';
30421
30422      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30423      var ttJs='var tt=document.getElementById("ox-tt");'+
30424        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30425        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30426        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30427        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30428        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30429        'function oxHT(){tt.style.display="none";}';
30430
30431      // body max-width keeps charts from inflating beyond design dimensions on
30432      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30433      // each chart's height blows up proportionally, breaking the one-page layout.
30434      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;}'+
30435        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30436        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30437        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30438        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30439        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30440        'svg{display:block;}'+
30441        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30442        '#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;}'+
30443        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30444      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30445        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30446        '<div id="ox-tt"><\/div>'+
30447        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
30448        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
30449        '<div class="two-col">'+
30450        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
30451        '<div class="leg">'+
30452        '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30453        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30454        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30455        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
30456        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30457        '<\/div>'+
30458        '<div class="two-col">'+
30459        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30460        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30461        '<\/div>'+
30462        '<script>'+ttJs+'<\/script>'+
30463        '<\/body><\/html>';
30464      slocDownload(html, fname, 'text/html;charset=utf-8;');
30465    }
30466    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30467    window.buildDeltaChartsHtml = function() {
30468      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30469      var sd=_sd;
30470      var projEl=document.querySelector('[data-folder]');
30471      var proj=projEl?projEl.getAttribute('data-folder'):'';
30472      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30473      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30474      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30475      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30476      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";}';
30477      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);}';
30478      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30479        '<div id="ox-tt"><\/div>'+
30480        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30481        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30482        '<div class="two-col">'+
30483        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30484        '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30485        '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30486        '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30487        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30488        '<\/div>'+
30489        '<div class="two-col">'+
30490        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30491        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30492        '<\/div>'+
30493        '<script>'+ttJs+'<\/script>'+
30494        '<\/body><\/html>';
30495    };
30496    // ── Inline delta charts ────────────────────────────────────────────────────
30497    var _icTT=document.getElementById('ic-tt');
30498    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30499    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';};
30500    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30501    window.addEventListener('blur',function(){window.icHT();});
30502    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30503    (function(){
30504      // Theme-aware palette — matches the canonical scheme used by /test-metrics
30505      // charts so every page renders bars/text/grid with the same colours and
30506      // adapts to dark mode (see Design section in CLAUDE.md).
30507      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30508      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30509      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30510      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30511      // washed) so the chart reads with the same weight as /test-metrics.
30512      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30513      var FADE=dark?'#524238':'#e6d0bf';
30514      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30515      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30516      function fmt(n){return Number(n).toLocaleString();}
30517      function px(n){return Math.round(n);}
30518      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30519      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30520      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);});}
30521      var dr=getDeltaExportRows(),sd=_sd,lm={};
30522      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;});
30523      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30524      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30525      // the bars are as tall as the (usually taller) Language Code Delta sibling that
30526      // shares the same grid row, instead of sitting short at the top.
30527      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}];
30528      function drawC1(){
30529        var C1W=600,C1H=188;
30530        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30531        if(host&&card&&host.clientWidth>0){
30532          var avW=host.clientWidth;
30533          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30534          var wantH=availPx*C1W/avW;
30535          if(wantH>C1H)C1H=wantH;
30536        }
30537        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30538        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30539        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"/>';}
30540        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30541        c1mets.forEach(function(m,i){
30542          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30543          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30544          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30545          var gMax=Math.max(m.b,m.c)*1.15||1;
30546          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30547          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>';
30548          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"/>';
30549          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>';
30550          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"/>';
30551          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>';
30552          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>';
30553          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>';
30554        });
30555        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>';
30556        c1+='</svg>';
30557        return c1;
30558      }
30559      var c1=drawC1();
30560      // Chart 2: Delta by Metric
30561      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}];
30562      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30563      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;
30564      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30565      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30566      mets.forEach(function(m,i){
30567        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);
30568        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>';
30569        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30570        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>';}
30571        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>';}
30572      });
30573      c2+='</svg>';
30574      // Chart 3: Language Code Delta
30575      var c3='';
30576      if(langs.length){
30577        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30578        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;
30579        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30580        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30581        langs.forEach(function(l,i){
30582          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);
30583          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>';
30584          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"/>';
30585          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>';}
30586          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>';}
30587          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>';
30588        });
30589        c3+='</svg>';
30590      }
30591      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30592      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30593      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;});
30594      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30595      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30596      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);
30597      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;
30598      if(segs.length===1){
30599        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30600        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+'"/>';
30601      } else {
30602        // Give every visible slice a small minimum sweep, taken from the largest
30603        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30604        // arc whose start and end points coincide, so SVG renders nothing (blank).
30605        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30606        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30607        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30608        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30609        segs.forEach(function(s,si){
30610          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30611          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);
30612          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);
30613          var pct=Math.round(s.v/tot*100);
30614          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"/>';
30615          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>';}
30616          ang+=sw;
30617        });
30618      }
30619      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30620      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30621      segs.forEach(function(s,i){
30622        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30623        c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30624        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30625        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30626        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30627        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>';
30628        c4+='</g>';
30629      });
30630      c4+='</svg>';
30631      // Inject the fixed-height siblings first so the grid row settles to the (taller)
30632      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30633      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30634      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);}
30635      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30636      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30637      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30638
30639      // Compare Timeline chart (Baseline vs Current, 2 points)
30640      (function() {
30641        var activeCmpMetric='code';
30642        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30643        function renderCmpTL(metric, targetSvg, targetH) {
30644          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30645          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30646          svg.setAttribute('height',H);
30647          var pad={l:62,r:20,t:32,b:72};
30648          var dark=document.body.classList.contains('dark-theme');
30649          var cmpPts=[
30650            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30651            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30652          ];
30653          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30654          var valid=pts.filter(function(v){return v!=null;});
30655          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;}
30656          var minV=0,maxV=Math.max.apply(null,valid);
30657          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30658          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30659          var cx0=pad.l,cx1=pad.l+plotW;
30660          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30661          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30662          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30663          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30664          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30665          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();}
30666          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
30667          var parts=[];
30668          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30669          for(var gi=0;gi<5;gi++){
30670            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30671            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30672            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30673          }
30674          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+'"/>');
30675          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"/>');
30676          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30677                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30678          dotPts.forEach(function(pt){
30679            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>');
30680            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"/>');
30681            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>');
30682            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>');
30683          });
30684          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30685          svg.setAttribute('viewBox','0 0 '+W+' '+H);
30686          svg.innerHTML=parts.join('');
30687          // Hover: crosshair + tooltip (matches multi-scan timeline)
30688          var cmpTT=document.getElementById('ic-tt');
30689          svg.onmousemove=function(e){
30690            var rect=svg.getBoundingClientRect();
30691            var scaleX=W/rect.width;
30692            var mouseX=(e.clientX-rect.left)*scaleX;
30693            var nearest=-1,minDist=Infinity;
30694            var cxArr=[cx0,cx1];
30695            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;}}
30696            if(nearest<0)return;
30697            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30698            var xhair=svg.querySelector('.cmp-xhair');
30699            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30700            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"/>';
30701            if(!cmpTT)return;
30702            var clbl=cmpPts[nearest].label;
30703            var scanLbl=nearest===0?'Baseline':'Current';
30704            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>';
30705            var bx=rect.left+(nc/W*rect.width)+18;
30706            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30707            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30708          };
30709          svg.onmouseleave=function(){
30710            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30711            if(cmpTT)cmpTT.style.display='none';
30712          };
30713        }
30714        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30715          btn.addEventListener('click',function(){
30716            activeCmpMetric=this.dataset.cmpMetric;
30717            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30718            this.classList.add('active');
30719            renderCmpTL(activeCmpMetric);
30720          });
30721        });
30722        var ttgl=document.getElementById('theme-toggle');
30723        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30724        if(typeof ResizeObserver!=='undefined'){
30725          var cmpSvg=document.getElementById('cmp-tl-svg');
30726          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30727        }
30728        // Expose the timeline renderer + current metric so the Full View modal can
30729        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30730        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30731        window.__sdGetMetric=function(){return activeCmpMetric;};
30732        renderCmpTL(activeCmpMetric);
30733      })();
30734
30735      // HTML legend hover -> highlight matching SVG bars within the SAME card only
30736      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30737        var metric=leg.getAttribute('data-highlight');
30738        var parentCard=leg.closest('.ic-card');
30739        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30740        if(!chartEl)return;
30741        leg.addEventListener('mouseenter',function(){
30742          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30743            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';}
30744            else{x.style.opacity='0.28';}
30745          });
30746        });
30747        leg.addEventListener('mouseleave',function(){
30748          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30749        });
30750      });
30751
30752      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30753      (function(){
30754        var ov=document.getElementById('ic-svg-modal-ov');
30755        var body=document.getElementById('ic-svg-modal-body');
30756        var ttl=document.getElementById('ic-svg-modal-title');
30757        var closeBtn=document.getElementById('ic-svg-modal-close');
30758        if(!ov||!body)return;
30759        function close(){
30760          ov.classList.remove('open');body.innerHTML='';
30761          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30762          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30763        }
30764        function open(srcId,title){
30765          var src=document.getElementById(srcId);if(!src)return;
30766          if(ttl)ttl.textContent=title||'';
30767          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30768          // snapshot stretches and loses interactivity. Re-render it live into the modal at
30769          // full size instead — keeps proportions, animation, crosshair, tooltip and the
30770          // metric tabs working exactly like the inline chart.
30771          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30772            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30773            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30774            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('');
30775            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>';
30776            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30777            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30778            ov.classList.add('open');
30779            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30780            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;}
30781            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30782              b.addEventListener('click',function(){
30783                if(!window.__sdFvTL)return;
30784                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30785                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30786                this.classList.add('active');
30787                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30788              });
30789            });
30790            return;
30791          }
30792          var card=src.closest('.ic-card');
30793          var legHtml='';
30794          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30795          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30796          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;}
30797          body.innerHTML=legHtml+inner;
30798          var svg=body.querySelector('svg');
30799          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30800          addTT(body);
30801          ov.classList.add('open');
30802        }
30803        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30804          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30805        });
30806        if(closeBtn)closeBtn.addEventListener('click',close);
30807        ov.addEventListener('click',function(e){if(e.target===ov)close();});
30808        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30809      })();
30810
30811      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30812    })();
30813  </script>
30814  {{ toast_assets|safe }}
30815  <script nonce="{{ csp_nonce }}">
30816  (function(){
30817    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'}];
30818    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);});}
30819    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30820    function init(){
30821      var btn=document.getElementById('settings-btn');if(!btn)return;
30822      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30823      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>';
30824      document.body.appendChild(m);
30825      var g=document.getElementById('scheme-grid');
30826      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);});
30827      var cl=document.getElementById('settings-close');
30828      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);});})();
30829      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');});
30830      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30831      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30832    }
30833    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30834  }());
30835  </script>
30836  <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]';
30837  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;}
30838  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>
30839</body>
30840</html>
30841"##,
30842    ext = "html"
30843)]
30844// Template structs need many bool fields to pass Askama rendering flags.
30845#[allow(clippy::struct_excessive_bools)]
30846struct CompareTemplate {
30847    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
30848    loading_overlay: String,
30849    version: &'static str,
30850    project_label: String,
30851    baseline_git_commit: String,
30852    current_git_commit: String,
30853    baseline_run_id: String,
30854    current_run_id: String,
30855    baseline_run_id_short: String,
30856    current_run_id_short: String,
30857    baseline_timestamp: String,
30858    baseline_timestamp_utc_ms: i64,
30859    current_timestamp: String,
30860    current_timestamp_utc_ms: i64,
30861    project_path: String,
30862    baseline_code: u64,
30863    current_code: u64,
30864    code_lines_delta_str: String,
30865    code_lines_delta_class: String,
30866    baseline_files: u64,
30867    current_files: u64,
30868    files_analyzed_delta_str: String,
30869    files_analyzed_delta_class: String,
30870    baseline_comments: u64,
30871    current_comments: u64,
30872    comment_lines_delta_str: String,
30873    comment_lines_delta_class: String,
30874    baseline_code_fmt: String,
30875    current_code_fmt: String,
30876    baseline_files_fmt: String,
30877    current_files_fmt: String,
30878    baseline_comments_fmt: String,
30879    current_comments_fmt: String,
30880    code_lines_pct_str: String,
30881    files_analyzed_pct_str: String,
30882    comment_lines_pct_str: String,
30883    code_lines_added: i64,
30884    code_lines_removed: i64,
30885    /// Code lines residing in files modified between the two scans (current-scan counts).
30886    code_lines_modified: i64,
30887    /// Code lines residing in files identical between the two scans.
30888    code_lines_unmodified: i64,
30889    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
30890    new_scope: bool,
30891    churn_rate_str: String,
30892    churn_rate_class: String,
30893    scope_flag: bool,
30894    files_added: usize,
30895    files_removed: usize,
30896    files_modified: usize,
30897    files_unchanged: usize,
30898    file_rows: Vec<CompareFileDeltaRow>,
30899    baseline_git_author: Option<String>,
30900    current_git_author: Option<String>,
30901    baseline_git_branch: String,
30902    current_git_branch: String,
30903    baseline_git_tags: Option<String>,
30904    current_git_tags: Option<String>,
30905    baseline_git_commit_date: Option<String>,
30906    current_git_commit_date: Option<String>,
30907    project_name: String,
30908    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
30909    submodule_options: Vec<String>,
30910    /// True when either run has submodule data — controls whether the scope bar is shown.
30911    has_any_submodule_data: bool,
30912    /// The submodule currently being compared, if the `sub` query param was provided.
30913    active_submodule: Option<String>,
30914    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
30915    super_scope_active: bool,
30916    csp_nonce: String,
30917    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
30918    toast_assets: String,
30919    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
30920    coverage_delta_card: String,
30921    baseline_test_count: u64,
30922    current_test_count: u64,
30923    baseline_coverage_pct: Option<f64>,
30924    current_coverage_pct: Option<f64>,
30925}
30926
30927// ── LoginTemplate ──────────────────────────────────────────────────────────────
30928
30929#[derive(Template)]
30930#[template(
30931    source = r##"
30932<!doctype html>
30933<html lang="en">
30934<head>
30935  <meta charset="utf-8">
30936  <meta name="viewport" content="width=device-width, initial-scale=1">
30937  <title>OxideSLOC | Sign In</title>
30938  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30939  <style nonce="{{ csp_nonce }}">
30940    :root {
30941      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30942      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30943      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30944      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30945    }
30946    *{box-sizing:border-box;}
30947    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);}
30948    .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);}
30949    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30950    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30951    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30952    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30953    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30954    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30955    .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;}
30956    @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));}}
30957    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30958    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30959    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30960    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30961    .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;}
30962    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30963    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;}
30964    input[type=password]:focus{border-color:var(--oxide);}
30965    .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;}
30966    .btn:hover{opacity:.88;}
30967    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30968    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30969  </style>
30970</head>
30971<body>
30972  <div class="background-watermarks" aria-hidden="true">
30973    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30974    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30975    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30976    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30977    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30978    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30979    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30980  </div>
30981  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30982<nav class="top-nav">
30983  <a class="brand" href="/">
30984    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30985    <span class="brand-title">OxideSLOC</span>
30986  </a>
30987</nav>
30988<main class="page">
30989  <div class="card">
30990    <h1>Sign In</h1>
30991    <p class="subtitle">Enter the API key printed when the server started.</p>
30992    {% if has_error %}
30993    <div class="error">Incorrect API key — please try again.</div>
30994    {% endif %}
30995    <form method="POST" action="/auth/login">
30996      <input type="hidden" name="next" value="{{ next_url|e }}">
30997      <label for="key">API Key</label>
30998      <input id="key" type="password" name="key" autocomplete="current-password"
30999             placeholder="Paste your API key here" autofocus>
31000      <button type="submit" class="btn">Sign In</button>
31001    </form>
31002    <p class="hint">
31003      The API key was printed in the terminal when the server started.<br>
31004      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31005      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31006    </p>
31007  </div>
31008</main>
31009<script nonce="{{ csp_nonce }}">
31010(function() {
31011  (function randomizeWatermarks() {
31012    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31013    if (!wms.length) return;
31014    var placed = [];
31015    function tooClose(top, left) {
31016      for (var i = 0; i < placed.length; i++) {
31017        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31018        if (dt < 16 && dl < 12) return true;
31019      }
31020      return false;
31021    }
31022    function pick(leftBand) {
31023      for (var attempt = 0; attempt < 50; attempt++) {
31024        var top = Math.random() * 88 + 2;
31025        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31026        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31027      }
31028      var top = Math.random() * 88 + 2;
31029      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31030      placed.push([top, left]); return [top, left];
31031    }
31032    var half = Math.floor(wms.length / 2);
31033    wms.forEach(function (img, i) {
31034      var pos = pick(i < half);
31035      var size = Math.floor(Math.random() * 100 + 120);
31036      var rot = (Math.random() * 360).toFixed(1);
31037      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31038      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;
31039    });
31040  })();
31041  (function spawnCodeParticles() {
31042    var container = document.getElementById('code-particles');
31043    if (!container) return;
31044    var snippets = [
31045      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31046      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31047      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31048      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31049      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31050    ];
31051    var count = 38;
31052    for (var i = 0; i < count; i++) {
31053      (function(idx) {
31054        var el = document.createElement('span');
31055        el.className = 'code-particle';
31056        el.textContent = snippets[idx % snippets.length];
31057        var left = Math.random() * 94 + 2;
31058        var top = Math.random() * 88 + 6;
31059        var dur = (Math.random() * 10 + 9).toFixed(1);
31060        var delay = (Math.random() * 18).toFixed(1);
31061        var rot = (Math.random() * 26 - 13).toFixed(1);
31062        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31063        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31064        container.appendChild(el);
31065      })(i);
31066    }
31067  })();
31068})();
31069</script>
31070</body>
31071</html>
31072"##,
31073    ext = "html"
31074)]
31075pub(crate) struct LoginTemplate {
31076    pub(crate) csp_nonce: String,
31077    pub(crate) has_error: bool,
31078    pub(crate) next_url: String,
31079    pub(crate) lockout_threshold: u32,
31080}
31081
31082// ── REST API reference page ────────────────────────────────────────────────────
31083
31084#[derive(Template)]
31085#[template(
31086    source = r##"
31087<!doctype html>
31088<html lang="en">
31089<head>
31090  <meta charset="utf-8">
31091  <meta name="viewport" content="width=device-width, initial-scale=1">
31092  <title>OxideSLOC — REST API Reference</title>
31093  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31094  <style nonce="{{ csp_nonce }}">
31095    :root {
31096      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31097      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31098      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31099      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31100      --success:#16a34a;
31101    }
31102    body.dark-theme {
31103      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31104      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31105    }
31106    *{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;}
31107    .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);}
31108    .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;}
31109    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31110    .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));}
31111    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31112    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31113    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31114    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31115    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31116    @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; } }
31117    .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;}
31118    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31119    .nav-pill.active{background:rgba(255,255,255,0.22);}
31120    .nav-dropdown{position:relative;display:inline-flex;}
31121    .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;}
31122    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31123    .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;}
31124    .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;}
31125    .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);}
31126    .nav-dropdown-menu a:last-child{border-bottom:none;}
31127    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31128    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31129    .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;}
31130    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31131    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31132    .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;}
31133    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31134    .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);}
31135    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31136    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31137    .settings-modal-body{padding:14px 16px 16px;}
31138    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31139    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31140    .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;}
31141    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31142    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31143    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31144    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31145    .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;}
31146    .tz-select:focus{border-color:var(--oxide);}
31147    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31148    .page-header{margin-bottom:28px;}
31149    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31150    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31151    .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;}
31152    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31153    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31154    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31155    .callout strong{font-weight:800;}
31156    .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;}
31157    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31158    .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;}
31159    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31160    .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;}
31161    body.dark-theme .base-url-value{color:var(--accent);}
31162    .section{margin-bottom:36px;}
31163    .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);}
31164    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31165    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31166    .ep-header:hover{background:var(--surface-2);}
31167    .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;}
31168    .method.get{background:#dcfce7;color:#166534;}
31169    .method.post{background:#dbeafe;color:#1e40af;}
31170    .method.delete{background:#fee2e2;color:#991b1b;}
31171    body.dark-theme .method.get{background:#14532d;color:#86efac;}
31172    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31173    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31174    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31175    .ep-path .param{color:var(--oxide-2);}
31176    body.dark-theme .ep-path .param{color:var(--oxide);}
31177    .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;}
31178    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31179    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31180    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31181    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31182    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31183    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31184    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31185    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31186    .ep-card.open .chevron{transform:rotate(180deg);}
31187    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31188    .ep-card.open .ep-body{display:block;}
31189    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31190    .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;}
31191    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31192    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31193    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31194    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31195    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);}
31196    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31197    table.params tr:last-child td{border-bottom:none;}
31198    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31199    .pt-type{color:var(--muted-2);font-size:12px;}
31200    .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;}
31201    .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;}
31202    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31203    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31204    details.schema{margin-bottom:14px;}
31205    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;}
31206    details.schema summary:hover{color:var(--text);}
31207    .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;}
31208    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31209    .curl-wrap{position:relative;}
31210    .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;}
31211    .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;}
31212    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31213    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31214    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31215    .webhook-note a{color:var(--accent-2);text-decoration:none;}
31216    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31217    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31218    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31219    .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;}
31220    @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));}}
31221    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31222    .site-footer a{color:var(--muted);}
31223  </style>
31224</head>
31225<body>
31226  <div class="background-watermarks" aria-hidden="true">
31227    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31228    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31229    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31230    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31231    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31232    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31233    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31234  </div>
31235  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31236  <div class="top-nav">
31237    <div class="top-nav-inner">
31238      <a class="brand" href="/">
31239        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31240        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31241      </a>
31242      <div class="nav-right">
31243        <a class="nav-pill" href="/">Home</a>
31244        <div class="nav-dropdown">
31245          <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>
31246          <div class="nav-dropdown-menu">
31247            <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>
31248          </div>
31249        </div>
31250        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31251        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31252        <div class="nav-dropdown">
31253          <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>
31254          <div class="nav-dropdown-menu">
31255            <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>
31256          </div>
31257        </div>
31258        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31259          <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>
31260        </button>
31261        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31262          <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>
31263          <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>
31264        </button>
31265      </div>
31266    </div>
31267  </div>
31268
31269  <div class="page">
31270    <div class="page-header">
31271      <h1 class="page-title">REST API Reference</h1>
31272      <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>
31273    </div>
31274
31275    {% if has_api_key %}
31276    <div class="callout key-set">
31277      <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>
31278      <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>
31279    </div>
31280    {% else %}
31281    <div class="callout no-key">
31282      <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>
31283      <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>
31284    </div>
31285    {% endif %}
31286
31287    <div class="base-url-bar">
31288      <span class="base-url-label">Base URL</span>
31289      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31290    </div>
31291
31292    <!-- Health -->
31293    <div class="section">
31294      <h2 class="section-title">Health &amp; Status</h2>
31295      <div class="ep-card">
31296        <div class="ep-header">
31297          <span class="method get">GET</span>
31298          <span class="ep-path">/healthz</span>
31299          <span class="auth-badge public">Public</span>
31300          <span class="ep-desc">Server liveness check</span>
31301          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31302        </div>
31303        <div class="ep-body">
31304          <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>
31305          <p class="params-heading">Response</p>
31306          <div class="schema-block">200 OK
31307Content-Type: text/plain
31308
31309ok</div>
31310          <p class="curl-heading">Example</p>
31311          <div class="curl-wrap">
31312            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31313            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31314          </div>
31315        </div>
31316      </div>
31317    </div>
31318
31319    <!-- Badges -->
31320    <div class="section">
31321      <h2 class="section-title">Badges</h2>
31322      <div class="ep-card">
31323        <div class="ep-header">
31324          <span class="method get">GET</span>
31325          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31326          <span class="auth-badge public">Public</span>
31327          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31328          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31329        </div>
31330        <div class="ep-body">
31331          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31332          <p class="params-heading">Path Parameters</p>
31333          <table class="params">
31334            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31335            <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>
31336          </table>
31337          <p class="curl-heading">Example</p>
31338          <div class="curl-wrap">
31339            <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>
31340            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31341          </div>
31342        </div>
31343      </div>
31344    </div>
31345
31346    <!-- Metrics -->
31347    <div class="section">
31348      <h2 class="section-title">Metrics</h2>
31349
31350      <div class="ep-card">
31351        <div class="ep-header">
31352          <span class="method get">GET</span>
31353          <span class="ep-path">/api/metrics/latest</span>
31354          <span class="auth-badge protected">Protected</span>
31355          <span class="ep-desc">Latest scan metrics (JSON)</span>
31356          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31357        </div>
31358        <div class="ep-body">
31359          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31360          <details class="schema"><summary>Response schema</summary>
31361<div class="schema-block">{
31362  "run_id":    string,        // UUID
31363  "timestamp": string,        // ISO-8601 UTC
31364  "project":   string,        // scanned root path
31365  "summary": {
31366    "files_analyzed":       number,
31367    "files_skipped":        number,
31368    "code_lines":           number,
31369    "comment_lines":        number,
31370    "blank_lines":          number,
31371    "total_physical_lines": number,
31372    "functions":            number,
31373    "classes":              number,
31374    "variables":            number,
31375    "imports":              number
31376  },
31377  "languages": [
31378    { "name": string, "files": number, "code_lines": number,
31379      "comment_lines": number, "blank_lines": number,
31380      "functions": number, "classes": number,
31381      "variables": number, "imports": number }
31382  ]
31383}</div></details>
31384          <p class="curl-heading">Example</p>
31385          <div class="curl-wrap">
31386            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31387  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31388            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31389          </div>
31390        </div>
31391      </div>
31392
31393      <div class="ep-card">
31394        <div class="ep-header">
31395          <span class="method get">GET</span>
31396          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31397          <span class="auth-badge protected">Protected</span>
31398          <span class="ep-desc">Metrics for a specific run</span>
31399          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31400        </div>
31401        <div class="ep-body">
31402          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31403          <p class="params-heading">Path Parameters</p>
31404          <table class="params">
31405            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31406            <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>
31407          </table>
31408          <p class="curl-heading">Example</p>
31409          <div class="curl-wrap">
31410            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31411  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
31412            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31413          </div>
31414        </div>
31415      </div>
31416
31417      <div class="ep-card">
31418        <div class="ep-header">
31419          <span class="method get">GET</span>
31420          <span class="ep-path">/api/metrics/history</span>
31421          <span class="auth-badge protected">Protected</span>
31422          <span class="ep-desc">Paginated scan history</span>
31423          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31424        </div>
31425        <div class="ep-body">
31426          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31427          <p class="params-heading">Query Parameters</p>
31428          <table class="params">
31429            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31430            <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>
31431            <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>
31432          </table>
31433          <details class="schema"><summary>Response schema</summary>
31434<div class="schema-block">[{
31435  "run_id":         string,
31436  "timestamp":      string,   // ISO-8601 UTC
31437  "commit":         string | null,
31438  "branch":         string | null,
31439  "tags":           string[],
31440  "code_lines":     number,
31441  "comment_lines":  number,
31442  "blank_lines":    number,
31443  "physical_lines": number,
31444  "files_analyzed": number,
31445  "project_label":  string,
31446  "html_url":       string | null
31447}]</div></details>
31448          <p class="curl-heading">Example</p>
31449          <div class="curl-wrap">
31450            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31451  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31452            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31453          </div>
31454        </div>
31455      </div>
31456
31457      <div class="ep-card">
31458        <div class="ep-header">
31459          <span class="method get">GET</span>
31460          <span class="ep-path">/api/project-history</span>
31461          <span class="auth-badge protected">Protected</span>
31462          <span class="ep-desc">Project-level scan summary</span>
31463          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31464        </div>
31465        <div class="ep-body">
31466          <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>
31467          <p class="params-heading">Query Parameters</p>
31468          <table class="params">
31469            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31470            <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>
31471          </table>
31472          <details class="schema"><summary>Response schema</summary>
31473<div class="schema-block">{
31474  "scan_count":           number,
31475  "last_scan_id":         string | null,
31476  "last_scan_timestamp":  string | null,  // ISO-8601
31477  "last_scan_code_lines": number | null,
31478  "last_git_branch":      string | null,
31479  "last_git_commit":      string | null
31480}</div></details>
31481          <p class="curl-heading">Example</p>
31482          <div class="curl-wrap">
31483            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31484  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31485            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31486          </div>
31487        </div>
31488      </div>
31489
31490      <div class="ep-card">
31491        <div class="ep-header">
31492          <span class="method get">GET</span>
31493          <span class="ep-path">/api/metrics/submodules</span>
31494          <span class="auth-badge protected">Protected</span>
31495          <span class="ep-desc">List known git submodules across scans</span>
31496          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31497        </div>
31498        <div class="ep-body">
31499          <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>
31500          <p class="params-heading">Query Parameters</p>
31501          <table class="params">
31502            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31503            <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>
31504          </table>
31505          <details class="schema"><summary>Response schema</summary>
31506<div class="schema-block">[{
31507  "name":          string,  // submodule name
31508  "relative_path": string   // path relative to the project root
31509}]</div></details>
31510          <p class="curl-heading">Example</p>
31511          <div class="curl-wrap">
31512            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31513  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31514            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31515          </div>
31516        </div>
31517      </div>
31518    </div>
31519
31520    <!-- Async Run Status -->
31521    <div class="section">
31522      <h2 class="section-title">Async Run Status</h2>
31523
31524      <div class="ep-card">
31525        <div class="ep-header">
31526          <span class="method get">GET</span>
31527          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31528          <span class="auth-badge protected">Protected</span>
31529          <span class="ep-desc">Poll scan completion</span>
31530          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31531        </div>
31532        <div class="ep-body">
31533          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31534          <details class="schema"><summary>Response schema</summary>
31535<div class="schema-block">// Running
31536{ "state": "running",  "elapsed_secs": number }
31537
31538// Complete
31539{ "state": "complete", "run_id": string }
31540
31541// Failed
31542{ "state": "failed",   "message": string }</div></details>
31543          <p class="curl-heading">Example</p>
31544          <div class="curl-wrap">
31545            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31546  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
31547            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31548          </div>
31549        </div>
31550      </div>
31551
31552      <div class="ep-card">
31553        <div class="ep-header">
31554          <span class="method get">GET</span>
31555          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31556          <span class="auth-badge protected">Protected</span>
31557          <span class="ep-desc">Poll PDF generation readiness</span>
31558          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31559        </div>
31560        <div class="ep-body">
31561          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31562          <details class="schema"><summary>Response schema</summary>
31563<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31564          <p class="curl-heading">Example</p>
31565          <div class="curl-wrap">
31566            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31567  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
31568            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31569          </div>
31570        </div>
31571      </div>
31572
31573      <div class="ep-card">
31574        <div class="ep-header">
31575          <span class="method post">POST</span>
31576          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31577          <span class="auth-badge protected">Protected</span>
31578          <span class="ep-desc">Cancel a running scan</span>
31579          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31580        </div>
31581        <div class="ep-body">
31582          <p class="ep-desc-full">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>
31583          <p class="curl-heading">Example</p>
31584          <div class="curl-wrap">
31585            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31586  -H "Authorization: Bearer $SLOC_API_KEY" \
31587  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
31588            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31589          </div>
31590        </div>
31591      </div>
31592    </div>
31593
31594    <!-- Run Management -->
31595    <div class="section">
31596      <h2 class="section-title">Run Management</h2>
31597
31598      <div class="ep-card">
31599        <div class="ep-header">
31600          <span class="method get">GET</span>
31601          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31602          <span class="auth-badge protected">Protected</span>
31603          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31604          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31605        </div>
31606        <div class="ep-body">
31607          <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>
31608          <p class="params-heading">Path Parameters</p>
31609          <table class="params">
31610            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31611            <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>
31612          </table>
31613          <details class="schema"><summary>Response</summary>
31614<div class="schema-block">200 OK — Content-Type: application/zip
31615Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
31616
31617404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
31618          <p class="curl-heading">Example</p>
31619          <div class="curl-wrap">
31620            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31621  -o run.zip \
31622  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
31623            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31624          </div>
31625        </div>
31626      </div>
31627
31628      <div class="ep-card">
31629        <div class="ep-header">
31630          <span class="method delete">DELETE</span>
31631          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31632          <span class="auth-badge protected">Protected</span>
31633          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31634          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31635        </div>
31636        <div class="ep-body">
31637          <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>
31638          <p class="params-heading">Path Parameters</p>
31639          <table class="params">
31640            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31641            <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>
31642          </table>
31643          <details class="schema"><summary>Response</summary>
31644<div class="schema-block">204 No Content — run successfully deleted
31645
31646500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
31647          <p class="curl-heading">Example</p>
31648          <div class="curl-wrap">
31649            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31650  -H "Authorization: Bearer $SLOC_API_KEY" \
31651  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
31652            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31653          </div>
31654        </div>
31655      </div>
31656
31657      <div class="ep-card">
31658        <div class="ep-header">
31659          <span class="method post">POST</span>
31660          <span class="ep-path">/api/runs/cleanup</span>
31661          <span class="auth-badge protected">Protected</span>
31662          <span class="ep-desc">Bulk delete runs older than N days</span>
31663          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31664        </div>
31665        <div class="ep-body">
31666          <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>
31667          <p class="params-heading">Request Body (application/json)</p>
31668          <table class="params">
31669            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31670            <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>
31671          </table>
31672          <details class="schema"><summary>Response schema</summary>
31673<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
31674          <p class="curl-heading">Example — delete runs older than 60 days</p>
31675          <div class="curl-wrap">
31676            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31677  -H "Authorization: Bearer $SLOC_API_KEY" \
31678  -H "Content-Type: application/json" \
31679  -d '{"older_than_days":60}' \
31680  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31681            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31682          </div>
31683        </div>
31684      </div>
31685    </div>
31686
31687    <!-- Retention Policy -->
31688    <div class="section">
31689      <h2 class="section-title">Retention Policy</h2>
31690
31691      <div class="ep-card">
31692        <div class="ep-header">
31693          <span class="method get">GET</span>
31694          <span class="ep-path">/api/cleanup-policy</span>
31695          <span class="auth-badge protected">Protected</span>
31696          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31697          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31698        </div>
31699        <div class="ep-body">
31700          <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>
31701          <details class="schema"><summary>Response schema</summary>
31702<div class="schema-block">{
31703  "policy": {
31704    "enabled":       boolean,
31705    "max_age_days":  number | null,   // delete runs older than N days
31706    "max_run_count": number | null,   // keep only the N most recent runs
31707    "interval_hours": number          // hours between background passes
31708  } | null,
31709  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
31710  "last_run_deleted": number | null   // runs deleted in last pass
31711}</div></details>
31712          <p class="curl-heading">Example</p>
31713          <div class="curl-wrap">
31714            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31715  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31716            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31717          </div>
31718        </div>
31719      </div>
31720
31721      <div class="ep-card">
31722        <div class="ep-header">
31723          <span class="method post">POST</span>
31724          <span class="ep-path">/api/cleanup-policy</span>
31725          <span class="auth-badge protected">Protected</span>
31726          <span class="ep-desc">Save or update the retention policy</span>
31727          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31728        </div>
31729        <div class="ep-body">
31730          <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>
31731          <p class="params-heading">Request Body (application/json)</p>
31732          <table class="params">
31733            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31734            <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>
31735            <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>
31736            <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>
31737            <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>
31738          </table>
31739          <details class="schema"><summary>Response</summary>
31740<div class="schema-block">204 No Content — policy saved and task (re)started
31741
31742500 Internal Server Error — { "error": string }</div></details>
31743          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31744          <div class="curl-wrap">
31745            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31746  -H "Authorization: Bearer $SLOC_API_KEY" \
31747  -H "Content-Type: application/json" \
31748  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31749  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31750            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31751          </div>
31752        </div>
31753      </div>
31754
31755      <div class="ep-card">
31756        <div class="ep-header">
31757          <span class="method post">POST</span>
31758          <span class="ep-path">/api/cleanup-policy/run-now</span>
31759          <span class="auth-badge protected">Protected</span>
31760          <span class="ep-desc">Trigger an immediate cleanup pass</span>
31761          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31762        </div>
31763        <div class="ep-body">
31764          <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>
31765          <details class="schema"><summary>Response schema</summary>
31766<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
31767          <p class="curl-heading">Example</p>
31768          <div class="curl-wrap">
31769            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31770  -H "Authorization: Bearer $SLOC_API_KEY" \
31771  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31772            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31773          </div>
31774        </div>
31775      </div>
31776
31777      <div class="ep-card">
31778        <div class="ep-header">
31779          <span class="method delete">DELETE</span>
31780          <span class="ep-path">/api/cleanup-policy</span>
31781          <span class="auth-badge protected">Protected</span>
31782          <span class="ep-desc">Remove the retention policy and stop the background task</span>
31783          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31784        </div>
31785        <div class="ep-body">
31786          <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>
31787          <details class="schema"><summary>Response</summary>
31788<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31789          <p class="curl-heading">Example</p>
31790          <div class="curl-wrap">
31791            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31792  -H "Authorization: Bearer $SLOC_API_KEY" \
31793  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31794            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31795          </div>
31796        </div>
31797      </div>
31798    </div>
31799
31800    <!-- Scan Profiles -->
31801    <div class="section">
31802      <h2 class="section-title">Scan Profiles</h2>
31803
31804      <div class="ep-card">
31805        <div class="ep-header">
31806          <span class="method get">GET</span>
31807          <span class="ep-path">/api/scan-profiles</span>
31808          <span class="auth-badge protected">Protected</span>
31809          <span class="ep-desc">List saved scan profiles</span>
31810          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31811        </div>
31812        <div class="ep-body">
31813          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31814          <details class="schema"><summary>Response schema</summary>
31815<div class="schema-block">{
31816  "profiles": [{
31817    "id":         string,   // UUID
31818    "name":       string,
31819    "created_at": string,   // ISO-8601
31820    "params":     object
31821  }]
31822}</div></details>
31823          <p class="curl-heading">Example</p>
31824          <div class="curl-wrap">
31825            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31826  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31827            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31828          </div>
31829        </div>
31830      </div>
31831
31832      <div class="ep-card">
31833        <div class="ep-header">
31834          <span class="method post">POST</span>
31835          <span class="ep-path">/api/scan-profiles</span>
31836          <span class="auth-badge protected">Protected</span>
31837          <span class="ep-desc">Save a scan profile</span>
31838          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31839        </div>
31840        <div class="ep-body">
31841          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31842          <p class="params-heading">Request Body (application/json)</p>
31843          <table class="params">
31844            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31845            <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>
31846            <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>
31847          </table>
31848          <details class="schema"><summary>Response schema</summary>
31849<div class="schema-block">{ "ok": true }</div></details>
31850          <p class="curl-heading">Example</p>
31851          <div class="curl-wrap">
31852            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31853  -H "Authorization: Bearer $SLOC_API_KEY" \
31854  -H "Content-Type: application/json" \
31855  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31856  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31857            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31858          </div>
31859        </div>
31860      </div>
31861
31862      <div class="ep-card">
31863        <div class="ep-header">
31864          <span class="method delete">DELETE</span>
31865          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31866          <span class="auth-badge protected">Protected</span>
31867          <span class="ep-desc">Delete a scan profile</span>
31868          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31869        </div>
31870        <div class="ep-body">
31871          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31872          <p class="params-heading">Path Parameters</p>
31873          <table class="params">
31874            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31875            <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>
31876          </table>
31877          <details class="schema"><summary>Response schema</summary>
31878<div class="schema-block">{ "ok": true }</div></details>
31879          <p class="curl-heading">Example</p>
31880          <div class="curl-wrap">
31881            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31882  -H "Authorization: Bearer $SLOC_API_KEY" \
31883  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
31884            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31885          </div>
31886        </div>
31887      </div>
31888    </div>
31889
31890    <!-- Scheduled Scans -->
31891    <div class="section">
31892      <h2 class="section-title">Scheduled Scans</h2>
31893
31894      <div class="ep-card">
31895        <div class="ep-header">
31896          <span class="method get">GET</span>
31897          <span class="ep-path">/api/schedules</span>
31898          <span class="auth-badge protected">Protected</span>
31899          <span class="ep-desc">List configured schedules</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 all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31904          <p class="curl-heading">Example</p>
31905          <div class="curl-wrap">
31906            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31907  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31908            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31909          </div>
31910        </div>
31911      </div>
31912
31913      <div class="ep-card">
31914        <div class="ep-header">
31915          <span class="method post">POST</span>
31916          <span class="ep-path">/api/schedules</span>
31917          <span class="auth-badge protected">Protected</span>
31918          <span class="ep-desc">Create a schedule</span>
31919          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31920        </div>
31921        <div class="ep-body">
31922          <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>
31923          <p class="curl-heading">Example</p>
31924          <div class="curl-wrap">
31925            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31926  -H "Authorization: Bearer $SLOC_API_KEY" \
31927  -H "Content-Type: application/json" \
31928  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31929  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31930            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31931          </div>
31932        </div>
31933      </div>
31934
31935      <div class="ep-card">
31936        <div class="ep-header">
31937          <span class="method delete">DELETE</span>
31938          <span class="ep-path">/api/schedules</span>
31939          <span class="auth-badge protected">Protected</span>
31940          <span class="ep-desc">Delete a schedule</span>
31941          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31942        </div>
31943        <div class="ep-body">
31944          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31945          <p class="curl-heading">Example</p>
31946          <div class="curl-wrap">
31947            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31948  -H "Authorization: Bearer $SLOC_API_KEY" \
31949  -H "Content-Type: application/json" \
31950  -d '{"id":"&lt;schedule_id&gt;"}' \
31951  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31952            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31953          </div>
31954        </div>
31955      </div>
31956    </div>
31957
31958    <!-- Git Browser -->
31959    <div class="section">
31960      <h2 class="section-title">Git Browser</h2>
31961
31962      <div class="ep-card">
31963        <div class="ep-header">
31964          <span class="method get">GET</span>
31965          <span class="ep-path">/api/git/refs</span>
31966          <span class="auth-badge protected">Protected</span>
31967          <span class="ep-desc">List git refs for a repository</span>
31968          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31969        </div>
31970        <div class="ep-body">
31971          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31972          <p class="params-heading">Query Parameters</p>
31973          <table class="params">
31974            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31975            <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>
31976          </table>
31977          <p class="curl-heading">Example</p>
31978          <div class="curl-wrap">
31979            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31980  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31981            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31982          </div>
31983        </div>
31984      </div>
31985
31986      <div class="ep-card">
31987        <div class="ep-header">
31988          <span class="method get">GET</span>
31989          <span class="ep-path">/api/git/scan-ref</span>
31990          <span class="auth-badge protected">Protected</span>
31991          <span class="ep-desc">SLOC-scan a specific git ref</span>
31992          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31993        </div>
31994        <div class="ep-body">
31995          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31996          <p class="params-heading">Query Parameters</p>
31997          <table class="params">
31998            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31999            <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>
32000            <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>
32001          </table>
32002          <p class="curl-heading">Example</p>
32003          <div class="curl-wrap">
32004            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32005  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
32006            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32007          </div>
32008        </div>
32009      </div>
32010
32011      <div class="ep-card">
32012        <div class="ep-header">
32013          <span class="method get">GET</span>
32014          <span class="ep-path">/api/git/compare-refs</span>
32015          <span class="auth-badge protected">Protected</span>
32016          <span class="ep-desc">Compare SLOC across two git refs</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">Runs SLOC analysis on two refs and returns the delta between them.</p>
32021          <p class="params-heading">Query Parameters</p>
32022          <table class="params">
32023            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32024            <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>
32025            <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>
32026            <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>
32027          </table>
32028          <p class="curl-heading">Example</p>
32029          <div class="curl-wrap">
32030            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32031  "<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>
32032            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32033          </div>
32034        </div>
32035      </div>
32036    </div>
32037
32038    <!-- Webhooks -->
32039    <div class="section">
32040      <h2 class="section-title">Webhooks</h2>
32041      <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>
32042
32043      <div class="ep-card">
32044        <div class="ep-header">
32045          <span class="method post">POST</span>
32046          <span class="ep-path">/webhooks/github</span>
32047          <span class="auth-badge hmac">HMAC</span>
32048          <span class="ep-desc">GitHub push event receiver</span>
32049          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32050        </div>
32051        <div class="ep-body">
32052          <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>
32053          <p class="params-heading">Required Headers</p>
32054          <table class="params">
32055            <tr><th>Header</th><th>Value</th></tr>
32056            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32057            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32058            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32059          </table>
32060        </div>
32061      </div>
32062
32063      <div class="ep-card">
32064        <div class="ep-header">
32065          <span class="method post">POST</span>
32066          <span class="ep-path">/webhooks/gitlab</span>
32067          <span class="auth-badge hmac">HMAC</span>
32068          <span class="ep-desc">GitLab push event receiver</span>
32069          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32070        </div>
32071        <div class="ep-body">
32072          <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>
32073          <p class="params-heading">Required Headers</p>
32074          <table class="params">
32075            <tr><th>Header</th><th>Value</th></tr>
32076            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32077            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32078            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32079          </table>
32080        </div>
32081      </div>
32082
32083      <div class="ep-card">
32084        <div class="ep-header">
32085          <span class="method post">POST</span>
32086          <span class="ep-path">/webhooks/bitbucket</span>
32087          <span class="auth-badge hmac">HMAC</span>
32088          <span class="ep-desc">Bitbucket push event receiver</span>
32089          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32090        </div>
32091        <div class="ep-body">
32092          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32093          <p class="params-heading">Required Headers</p>
32094          <table class="params">
32095            <tr><th>Header</th><th>Value</th></tr>
32096            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32097            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32098          </table>
32099        </div>
32100      </div>
32101    </div>
32102
32103    <!-- Config -->
32104    <div class="section">
32105      <h2 class="section-title">Config Import / Export</h2>
32106
32107      <div class="ep-card">
32108        <div class="ep-header">
32109          <span class="method get">GET</span>
32110          <span class="ep-path">/export-config</span>
32111          <span class="auth-badge protected">Protected</span>
32112          <span class="ep-desc">Export server configuration as JSON</span>
32113          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32114        </div>
32115        <div class="ep-body">
32116          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32117          <p class="curl-heading">Example</p>
32118          <div class="curl-wrap">
32119            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32120  -o config.json \
32121  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32122            <button class="curl-copy-btn" data-target="c-export">Copy</button>
32123          </div>
32124        </div>
32125      </div>
32126
32127      <div class="ep-card">
32128        <div class="ep-header">
32129          <span class="method post">POST</span>
32130          <span class="ep-path">/import-config</span>
32131          <span class="auth-badge protected">Protected</span>
32132          <span class="ep-desc">Import server configuration</span>
32133          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32134        </div>
32135        <div class="ep-body">
32136          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32137          <p class="curl-heading">Example</p>
32138          <div class="curl-wrap">
32139            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32140  -H "Authorization: Bearer $SLOC_API_KEY" \
32141  -H "Content-Type: application/json" \
32142  -d @config.json \
32143  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32144            <button class="curl-copy-btn" data-target="c-import">Copy</button>
32145          </div>
32146        </div>
32147      </div>
32148    </div>
32149
32150    <!-- CI Ingest -->
32151    <div class="section">
32152      <h2 class="section-title">CI Ingest</h2>
32153
32154      <div class="ep-card">
32155        <div class="ep-header">
32156          <span class="method post">POST</span>
32157          <span class="ep-path">/api/ingest</span>
32158          <span class="auth-badge protected">Protected</span>
32159          <span class="ep-desc">Push a pre-computed scan result from CI</span>
32160          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32161        </div>
32162        <div class="ep-body">
32163          <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>
32164          <p class="params-heading">Query Parameters</p>
32165          <table class="params">
32166            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32167            <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>
32168          </table>
32169          <p class="params-heading">Request Body (application/json)</p>
32170          <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>
32171          <details class="schema"><summary>Response schema</summary>
32172<div class="schema-block">// 201 Created
32173{
32174  "run_id":   string,  // UUID of the ingested run
32175  "view_url": string   // relative URL to the report page
32176}</div></details>
32177          <p class="curl-heading">Example</p>
32178          <div class="curl-wrap">
32179            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32180  -H "Authorization: Bearer $SLOC_API_KEY" \
32181  -H "Content-Type: application/json" \
32182  -d @result.json \
32183  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32184            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32185          </div>
32186        </div>
32187      </div>
32188    </div>
32189
32190    <!-- Artifact Download -->
32191    <div class="section">
32192      <h2 class="section-title">Artifact Download</h2>
32193
32194      <div class="ep-card">
32195        <div class="ep-header">
32196          <span class="method get">GET</span>
32197          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32198          <span class="auth-badge protected">Protected</span>
32199          <span class="ep-desc">Download or view a scan artifact</span>
32200          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32201        </div>
32202        <div class="ep-body">
32203          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32204          <p class="params-heading">Path Parameters</p>
32205          <table class="params">
32206            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32207            <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>
32208            <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>
32209          </table>
32210          <p class="params-heading">Query Parameters</p>
32211          <table class="params">
32212            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32213            <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>
32214          </table>
32215          <p class="curl-heading">Example — download JSON result</p>
32216          <div class="curl-wrap">
32217            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32218  -o result.json \
32219  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
32220            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32221          </div>
32222        </div>
32223      </div>
32224    </div>
32225
32226    <!-- Embed Widget -->
32227    <div class="section">
32228      <h2 class="section-title">Embed Widget</h2>
32229
32230      <div class="ep-card">
32231        <div class="ep-header">
32232          <span class="method get">GET</span>
32233          <span class="ep-path">/embed/summary</span>
32234          <span class="auth-badge protected">Protected</span>
32235          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32236          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32237        </div>
32238        <div class="ep-body">
32239          <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>
32240          <p class="params-heading">Query Parameters</p>
32241          <table class="params">
32242            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32243            <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>
32244            <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>
32245          </table>
32246          <p class="curl-heading">Example</p>
32247          <div class="curl-wrap">
32248            <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"
32249        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
32250            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32251          </div>
32252        </div>
32253      </div>
32254    </div>
32255
32256    <!-- Confluence Integration -->
32257    <div class="section">
32258      <h2 class="section-title">Confluence Integration</h2>
32259
32260      <div class="ep-card">
32261        <div class="ep-header">
32262          <span class="method get">GET</span>
32263          <span class="ep-path">/api/confluence/config</span>
32264          <span class="auth-badge protected">Protected</span>
32265          <span class="ep-desc">Get current Confluence configuration</span>
32266          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32267        </div>
32268        <div class="ep-body">
32269          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32270          <details class="schema"><summary>Response schema</summary>
32271<div class="schema-block">{
32272  "configured":     boolean,
32273  "tier":           "cloud" | "server",
32274  "base_url":       string,
32275  "username":       string,
32276  "api_token_set":  boolean,
32277  "space_key":      string,
32278  "parent_page_id": string | null,
32279  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
32280}</div></details>
32281          <p class="curl-heading">Example</p>
32282          <div class="curl-wrap">
32283            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32284  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32285            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32286          </div>
32287        </div>
32288      </div>
32289
32290      <div class="ep-card">
32291        <div class="ep-header">
32292          <span class="method post">POST</span>
32293          <span class="ep-path">/api/confluence/config</span>
32294          <span class="auth-badge protected">Protected</span>
32295          <span class="ep-desc">Save Confluence configuration</span>
32296          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32297        </div>
32298        <div class="ep-body">
32299          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32300          <p class="params-heading">Request Body (application/json)</p>
32301          <table class="params">
32302            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32303            <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>
32304            <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>
32305            <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>
32306            <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>
32307            <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>
32308            <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>
32309            <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>
32310          </table>
32311          <details class="schema"><summary>Response schema</summary>
32312<div class="schema-block">{ "ok": true }</div></details>
32313          <p class="curl-heading">Example</p>
32314          <div class="curl-wrap">
32315            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32316  -H "Authorization: Bearer $SLOC_API_KEY" \
32317  -H "Content-Type: application/json" \
32318  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32319  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32320            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32321          </div>
32322        </div>
32323      </div>
32324
32325      <div class="ep-card">
32326        <div class="ep-header">
32327          <span class="method post">POST</span>
32328          <span class="ep-path">/api/confluence/test</span>
32329          <span class="auth-badge protected">Protected</span>
32330          <span class="ep-desc">Test Confluence connection</span>
32331          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32332        </div>
32333        <div class="ep-body">
32334          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32335          <details class="schema"><summary>Response schema</summary>
32336<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32337          <p class="curl-heading">Example</p>
32338          <div class="curl-wrap">
32339            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32340  -H "Authorization: Bearer $SLOC_API_KEY" \
32341  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32342            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32343          </div>
32344        </div>
32345      </div>
32346
32347      <div class="ep-card">
32348        <div class="ep-header">
32349          <span class="method post">POST</span>
32350          <span class="ep-path">/api/confluence/post</span>
32351          <span class="auth-badge protected">Protected</span>
32352          <span class="ep-desc">Publish a scan report to Confluence</span>
32353          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32354        </div>
32355        <div class="ep-body">
32356          <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>
32357          <p class="params-heading">Request Body (application/json)</p>
32358          <table class="params">
32359            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32360            <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>
32361            <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>
32362            <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>
32363          </table>
32364          <details class="schema"><summary>Response schema</summary>
32365<div class="schema-block">// 200 OK
32366{ "ok": true, "page_id": string }
32367
32368// 400 / 502 on error
32369{ "ok": false, "error": string }</div></details>
32370          <p class="curl-heading">Example</p>
32371          <div class="curl-wrap">
32372            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32373  -H "Authorization: Bearer $SLOC_API_KEY" \
32374  -H "Content-Type: application/json" \
32375  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
32376  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32377            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32378          </div>
32379        </div>
32380      </div>
32381
32382      <div class="ep-card">
32383        <div class="ep-header">
32384          <span class="method get">GET</span>
32385          <span class="ep-path">/api/confluence/wiki-markup</span>
32386          <span class="auth-badge protected">Protected</span>
32387          <span class="ep-desc">Get Confluence wiki markup for a run</span>
32388          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32389        </div>
32390        <div class="ep-body">
32391          <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>
32392          <p class="params-heading">Query Parameters</p>
32393          <table class="params">
32394            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32395            <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>
32396          </table>
32397          <p class="curl-heading">Example</p>
32398          <div class="curl-wrap">
32399            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32400  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
32401            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32402          </div>
32403        </div>
32404      </div>
32405    </div>
32406
32407    <!-- Authentication -->
32408    <div class="section">
32409      <h2 class="section-title">Authentication</h2>
32410      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32411
32412      <div class="ep-card">
32413        <div class="ep-header">
32414          <span class="method get">GET</span>
32415          <span class="ep-path">/auth/login</span>
32416          <span class="auth-badge public">Public</span>
32417          <span class="ep-desc">Login page</span>
32418          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32419        </div>
32420        <div class="ep-body">
32421          <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>
32422          <p class="params-heading">Query Parameters</p>
32423          <table class="params">
32424            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32425            <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>
32426            <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>
32427          </table>
32428        </div>
32429      </div>
32430
32431      <div class="ep-card">
32432        <div class="ep-header">
32433          <span class="method post">POST</span>
32434          <span class="ep-path">/auth/login</span>
32435          <span class="auth-badge public">Public</span>
32436          <span class="ep-desc">Submit credentials and get a session cookie</span>
32437          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32438        </div>
32439        <div class="ep-body">
32440          <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>
32441          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32442          <table class="params">
32443            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32444            <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>
32445            <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>
32446          </table>
32447          <p class="curl-heading">Example</p>
32448          <div class="curl-wrap">
32449            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32450  -d "key=$SLOC_API_KEY&amp;next=/" \
32451  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32452            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32453          </div>
32454        </div>
32455      </div>
32456    </div>
32457
32458    <!-- Coverage Suggestion -->
32459    <div class="section">
32460      <h2 class="section-title">Coverage Suggestion</h2>
32461
32462      <div class="ep-card">
32463        <div class="ep-header">
32464          <span class="method get">GET</span>
32465          <span class="ep-path">/api/suggest-coverage</span>
32466          <span class="auth-badge protected">Protected</span>
32467          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32468          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32469        </div>
32470        <div class="ep-body">
32471          <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>
32472          <p class="params-heading">Query Parameters</p>
32473          <table class="params">
32474            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32475            <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>
32476          </table>
32477          <details class="schema"><summary>Response schema</summary>
32478<div class="schema-block">{
32479  "found": string | null,  // absolute path to the coverage file, if detected
32480  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32481  "hint":  string | null   // shell command to generate coverage if not found
32482}</div></details>
32483          <p class="curl-heading">Example</p>
32484          <div class="curl-wrap">
32485            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32486  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32487            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32488          </div>
32489        </div>
32490      </div>
32491    </div>
32492
32493  </div>
32494
32495  <footer class="site-footer">
32496    local code analysis - metrics, history and reports
32497    &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>
32498    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32499    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32500    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32501    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32502  </footer>
32503
32504  <script nonce="{{ csp_nonce }}">
32505    (function () {
32506      var base = window.location.origin;
32507      document.getElementById('base-url').textContent = base;
32508      document.querySelectorAll('.base-url-slot').forEach(function (el) {
32509        el.textContent = base;
32510      });
32511
32512      document.querySelectorAll('.ep-header').forEach(function (hdr) {
32513        hdr.addEventListener('click', function () {
32514          hdr.closest('.ep-card').classList.toggle('open');
32515        });
32516      });
32517
32518      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32519        btn.addEventListener('click', function () {
32520          var targetId = btn.dataset.target;
32521          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32522          if (!pre) return;
32523          navigator.clipboard.writeText(pre.textContent).then(function () {
32524            btn.textContent = 'Copied!';
32525            btn.classList.add('copied');
32526            setTimeout(function () {
32527              btn.textContent = 'Copy';
32528              btn.classList.remove('copied');
32529            }, 2000);
32530          });
32531        });
32532      });
32533
32534      var storageKey = 'oxide-sloc-theme';
32535      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32536      var themeBtn = document.getElementById('theme-toggle');
32537      if (themeBtn) {
32538        themeBtn.addEventListener('click', function () {
32539          var dark = document.body.classList.toggle('dark-theme');
32540          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32541        });
32542      }
32543      (function() {
32544        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'}];
32545        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);});}
32546        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32547        var btn=document.getElementById('settings-btn');if(!btn)return;
32548        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32549        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>';
32550        document.body.appendChild(m);
32551        var g=document.getElementById('scheme-grid');
32552        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);});
32553        var cl=document.getElementById('settings-close');
32554        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);});})();
32555        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');});
32556        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32557        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32558      })();
32559      (function randomizeWatermarks() {
32560        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32561        if (!wms.length) return;
32562        var placed = [];
32563        function tooClose(top, left) {
32564          for (var i = 0; i < placed.length; i++) {
32565            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32566            if (dt < 16 && dl < 12) return true;
32567          }
32568          return false;
32569        }
32570        function pick(leftBand) {
32571          for (var attempt = 0; attempt < 50; attempt++) {
32572            var top = Math.random() * 88 + 2;
32573            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32574            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32575          }
32576          var top = Math.random() * 88 + 2;
32577          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32578          placed.push([top, left]); return [top, left];
32579        }
32580        var half = Math.floor(wms.length / 2);
32581        wms.forEach(function (img, i) {
32582          var pos = pick(i < half);
32583          var size = Math.floor(Math.random() * 100 + 120);
32584          var rot = (Math.random() * 360).toFixed(1);
32585          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32586          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;
32587        });
32588      })();
32589      (function spawnCodeParticles() {
32590        var container = document.getElementById('code-particles');
32591        if (!container) return;
32592        var snippets = [
32593          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32594          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32595          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32596          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32597          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32598        ];
32599        var count = 38;
32600        for (var i = 0; i < count; i++) {
32601          (function(idx) {
32602            var el = document.createElement('span');
32603            el.className = 'code-particle';
32604            el.textContent = snippets[idx % snippets.length];
32605            var left = Math.random() * 94 + 2;
32606            var top = Math.random() * 88 + 6;
32607            var dur = (Math.random() * 10 + 9).toFixed(1);
32608            var delay = (Math.random() * 18).toFixed(1);
32609            var rot = (Math.random() * 26 - 13).toFixed(1);
32610            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32611            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32612            container.appendChild(el);
32613          })(i);
32614        }
32615      })();
32616    }());
32617  </script>
32618</body>
32619</html>
32620"##,
32621    ext = "html"
32622)]
32623struct ApiDocsTemplate {
32624    has_api_key: bool,
32625    csp_nonce: String,
32626    version: &'static str,
32627}
32628
32629#[cfg(test)]
32630mod form_config_tests {
32631    use super::*;
32632    use sloc_config::{
32633        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32634    };
32635
32636    fn blank_form() -> AnalyzeForm {
32637        AnalyzeForm {
32638            path: ".".to_string(),
32639            git_repo: None,
32640            git_ref: None,
32641            mixed_line_policy: None,
32642            python_docstrings_as_comments: None,
32643            generated_file_detection: None,
32644            minified_file_detection: None,
32645            vendor_directory_detection: None,
32646            include_lockfiles: None,
32647            binary_file_behavior: None,
32648            output_dir: None,
32649            report_title: None,
32650            report_header_footer: None,
32651            include_globs: None,
32652            exclude_globs: None,
32653            submodule_breakdown: None,
32654            coverage_file: None,
32655            continuation_line_policy: None,
32656            blank_in_block_comment_policy: None,
32657            count_compiler_directives: None,
32658            style_col_threshold: None,
32659            style_analysis_enabled: None,
32660            style_score_threshold: None,
32661            style_lang_scope: None,
32662            cocomo_mode: None,
32663            complexity_alert: None,
32664            exclude_duplicates: None,
32665            activity_window: None,
32666        }
32667    }
32668
32669    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32670        let mut cfg = sloc_config::AppConfig::default();
32671        apply_form_to_config(&mut cfg, form);
32672        cfg
32673    }
32674
32675    // ── activity_window (git hotspots — on by default) ──
32676
32677    #[test]
32678    fn extract_long_commit_picks_super_repo_by_short_prefix() {
32679        // A pretty-printed JSON tail containing several submodule git_commit_long
32680        // values plus the super-repo's; the helper must return the one whose hash
32681        // starts with the known short SHA, ignoring the others and any null value.
32682        let dir = tempfile::tempdir().unwrap();
32683        let path = dir.path().join("result.json");
32684        let body = r#"{
32685  "submodules": [
32686    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32687    { "git_commit_long": null }
32688  ],
32689  "git_commit_short": "4c2cd9b",
32690  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32691}"#;
32692        std::fs::write(&path, body).unwrap();
32693        assert_eq!(
32694            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32695            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32696        );
32697        // No match for an unrelated short SHA, and empty short yields None.
32698        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32699        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32700    }
32701
32702    #[test]
32703    fn activity_window_defaults_on_when_field_blank() {
32704        // Blank form field keeps the config default (90 days).
32705        let cfg = apply(&blank_form());
32706        assert_eq!(cfg.analysis.activity_window_days, Some(90));
32707    }
32708
32709    #[test]
32710    fn activity_window_override_sets_days() {
32711        let mut form = blank_form();
32712        form.activity_window = Some("30".to_string());
32713        let cfg = apply(&form);
32714        assert_eq!(cfg.analysis.activity_window_days, Some(30));
32715    }
32716
32717    #[test]
32718    fn activity_window_zero_disables() {
32719        // An explicit 0 from the form disables hotspots (overrides the default-on).
32720        let mut form = blank_form();
32721        form.activity_window = Some("0".to_string());
32722        let cfg = apply(&form);
32723        assert_eq!(cfg.analysis.activity_window_days, Some(0));
32724    }
32725
32726    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
32727
32728    #[test]
32729    fn python_docstrings_false_when_unchecked() {
32730        // Checkbox absent in form data (unchecked) → field must be false.
32731        let cfg = apply(&blank_form());
32732        assert!(
32733            !cfg.analysis.python_docstrings_as_comments,
32734            "absent python_docstrings_as_comments must map to false"
32735        );
32736    }
32737
32738    #[test]
32739    fn python_docstrings_true_when_checked() {
32740        // Browser sends "on" (no value= attr on the checkbox).
32741        let mut form = blank_form();
32742        form.python_docstrings_as_comments = Some("on".to_string());
32743        let cfg = apply(&form);
32744        assert!(cfg.analysis.python_docstrings_as_comments);
32745    }
32746
32747    #[test]
32748    fn python_docstrings_true_for_any_non_none_value() {
32749        // The handler uses .is_some() — any non-None value means "checked".
32750        let mut form = blank_form();
32751        form.python_docstrings_as_comments = Some("true".to_string());
32752        assert!(apply(&form).analysis.python_docstrings_as_comments);
32753    }
32754
32755    // ── submodule_breakdown (checkbox with value="enabled") ──
32756
32757    #[test]
32758    fn submodule_breakdown_false_when_unchecked() {
32759        let cfg = apply(&blank_form());
32760        assert!(
32761            !cfg.discovery.submodule_breakdown,
32762            "absent submodule_breakdown must map to false"
32763        );
32764    }
32765
32766    #[test]
32767    fn submodule_breakdown_true_when_value_enabled() {
32768        let mut form = blank_form();
32769        form.submodule_breakdown = Some("enabled".to_string());
32770        assert!(apply(&form).discovery.submodule_breakdown);
32771    }
32772
32773    #[test]
32774    fn submodule_breakdown_false_for_wrong_value() {
32775        // If somehow a value other than "enabled" is sent, it must still be false.
32776        let mut form = blank_form();
32777        form.submodule_breakdown = Some("on".to_string());
32778        assert!(
32779            !apply(&form).discovery.submodule_breakdown,
32780            "submodule_breakdown only becomes true for the exact value 'enabled'"
32781        );
32782    }
32783
32784    // ── generated_file_detection (select: "enabled" | "disabled") ──
32785
32786    #[test]
32787    fn generated_detection_true_when_enabled() {
32788        let mut form = blank_form();
32789        form.generated_file_detection = Some("enabled".to_string());
32790        assert!(apply(&form).analysis.generated_file_detection);
32791    }
32792
32793    #[test]
32794    fn generated_detection_false_when_disabled() {
32795        let mut form = blank_form();
32796        form.generated_file_detection = Some("disabled".to_string());
32797        assert!(!apply(&form).analysis.generated_file_detection);
32798    }
32799
32800    #[test]
32801    fn generated_detection_true_when_absent() {
32802        // None != Some("disabled") → true (safe default)
32803        assert!(
32804            apply(&blank_form()).analysis.generated_file_detection,
32805            "absent field must default to true (detection on)"
32806        );
32807    }
32808
32809    // ── minified_file_detection ──
32810
32811    #[test]
32812    fn minified_detection_false_when_disabled() {
32813        let mut form = blank_form();
32814        form.minified_file_detection = Some("disabled".to_string());
32815        assert!(!apply(&form).analysis.minified_file_detection);
32816    }
32817
32818    #[test]
32819    fn minified_detection_true_when_enabled() {
32820        let mut form = blank_form();
32821        form.minified_file_detection = Some("enabled".to_string());
32822        assert!(apply(&form).analysis.minified_file_detection);
32823    }
32824
32825    #[test]
32826    fn minified_detection_true_when_absent() {
32827        assert!(apply(&blank_form()).analysis.minified_file_detection);
32828    }
32829
32830    // ── vendor_directory_detection ──
32831
32832    #[test]
32833    fn vendor_detection_false_when_disabled() {
32834        let mut form = blank_form();
32835        form.vendor_directory_detection = Some("disabled".to_string());
32836        assert!(!apply(&form).analysis.vendor_directory_detection);
32837    }
32838
32839    #[test]
32840    fn vendor_detection_true_when_enabled() {
32841        let mut form = blank_form();
32842        form.vendor_directory_detection = Some("enabled".to_string());
32843        assert!(apply(&form).analysis.vendor_directory_detection);
32844    }
32845
32846    #[test]
32847    fn vendor_detection_true_when_absent() {
32848        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32849    }
32850
32851    // ── include_lockfiles (select: "disabled" default | "enabled") ──
32852
32853    #[test]
32854    fn lockfiles_false_when_absent() {
32855        // None == Some("enabled") is false → lockfiles off (correct safe default)
32856        assert!(!apply(&blank_form()).analysis.include_lockfiles);
32857    }
32858
32859    #[test]
32860    fn lockfiles_false_when_disabled() {
32861        let mut form = blank_form();
32862        form.include_lockfiles = Some("disabled".to_string());
32863        assert!(!apply(&form).analysis.include_lockfiles);
32864    }
32865
32866    #[test]
32867    fn lockfiles_true_when_enabled() {
32868        let mut form = blank_form();
32869        form.include_lockfiles = Some("enabled".to_string());
32870        assert!(apply(&form).analysis.include_lockfiles);
32871    }
32872
32873    // ── count_compiler_directives ──
32874
32875    #[test]
32876    fn compiler_directives_true_when_absent() {
32877        assert!(
32878            apply(&blank_form()).analysis.count_compiler_directives,
32879            "absent count_compiler_directives must default to true"
32880        );
32881    }
32882
32883    #[test]
32884    fn compiler_directives_true_when_enabled() {
32885        let mut form = blank_form();
32886        form.count_compiler_directives = Some("enabled".to_string());
32887        assert!(apply(&form).analysis.count_compiler_directives);
32888    }
32889
32890    #[test]
32891    fn compiler_directives_false_when_disabled() {
32892        let mut form = blank_form();
32893        form.count_compiler_directives = Some("disabled".to_string());
32894        assert!(!apply(&form).analysis.count_compiler_directives);
32895    }
32896
32897    // ── mixed_line_policy (enum select) ──
32898
32899    #[test]
32900    fn mixed_policy_unchanged_when_absent() {
32901        // None → if-let does nothing → stays at config default (CodeOnly)
32902        assert_eq!(
32903            apply(&blank_form()).analysis.mixed_line_policy,
32904            MixedLinePolicy::CodeOnly
32905        );
32906    }
32907
32908    #[test]
32909    fn mixed_policy_code_only() {
32910        let mut form = blank_form();
32911        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32912        assert_eq!(
32913            apply(&form).analysis.mixed_line_policy,
32914            MixedLinePolicy::CodeOnly
32915        );
32916    }
32917
32918    #[test]
32919    fn mixed_policy_code_and_comment() {
32920        let mut form = blank_form();
32921        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32922        assert_eq!(
32923            apply(&form).analysis.mixed_line_policy,
32924            MixedLinePolicy::CodeAndComment
32925        );
32926    }
32927
32928    #[test]
32929    fn mixed_policy_comment_only() {
32930        let mut form = blank_form();
32931        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32932        assert_eq!(
32933            apply(&form).analysis.mixed_line_policy,
32934            MixedLinePolicy::CommentOnly
32935        );
32936    }
32937
32938    #[test]
32939    fn mixed_policy_separate_mixed_category() {
32940        let mut form = blank_form();
32941        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32942        assert_eq!(
32943            apply(&form).analysis.mixed_line_policy,
32944            MixedLinePolicy::SeparateMixedCategory
32945        );
32946    }
32947
32948    // ── binary_file_behavior (enum select) ──
32949
32950    #[test]
32951    fn binary_behavior_skip_when_absent() {
32952        assert_eq!(
32953            apply(&blank_form()).analysis.binary_file_behavior,
32954            BinaryFileBehavior::Skip
32955        );
32956    }
32957
32958    #[test]
32959    fn binary_behavior_skip() {
32960        let mut form = blank_form();
32961        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32962        assert_eq!(
32963            apply(&form).analysis.binary_file_behavior,
32964            BinaryFileBehavior::Skip
32965        );
32966    }
32967
32968    #[test]
32969    fn binary_behavior_fail() {
32970        let mut form = blank_form();
32971        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32972        assert_eq!(
32973            apply(&form).analysis.binary_file_behavior,
32974            BinaryFileBehavior::Fail
32975        );
32976    }
32977
32978    // ── continuation_line_policy (enum select) ──
32979
32980    #[test]
32981    fn continuation_policy_each_physical_when_absent() {
32982        assert_eq!(
32983            apply(&blank_form()).analysis.continuation_line_policy,
32984            ContinuationLinePolicy::EachPhysicalLine
32985        );
32986    }
32987
32988    #[test]
32989    fn continuation_policy_collapse_to_logical() {
32990        let mut form = blank_form();
32991        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32992        assert_eq!(
32993            apply(&form).analysis.continuation_line_policy,
32994            ContinuationLinePolicy::CollapseToLogical
32995        );
32996    }
32997
32998    // ── blank_in_block_comment_policy (enum select) ──
32999
33000    #[test]
33001    fn blank_in_block_comment_count_as_comment_when_absent() {
33002        assert_eq!(
33003            apply(&blank_form()).analysis.blank_in_block_comment_policy,
33004            BlankInBlockCommentPolicy::CountAsComment
33005        );
33006    }
33007
33008    #[test]
33009    fn blank_in_block_comment_count_as_blank() {
33010        let mut form = blank_form();
33011        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33012        assert_eq!(
33013            apply(&form).analysis.blank_in_block_comment_policy,
33014            BlankInBlockCommentPolicy::CountAsBlank
33015        );
33016    }
33017
33018    // ── style_col_threshold ──
33019
33020    #[test]
33021    fn style_threshold_80() {
33022        let mut form = blank_form();
33023        form.style_col_threshold = Some("80".to_string());
33024        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33025    }
33026
33027    #[test]
33028    fn style_threshold_100() {
33029        let mut form = blank_form();
33030        form.style_col_threshold = Some("100".to_string());
33031        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33032    }
33033
33034    #[test]
33035    fn style_threshold_120() {
33036        let mut form = blank_form();
33037        form.style_col_threshold = Some("120".to_string());
33038        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33039    }
33040
33041    #[test]
33042    fn style_threshold_invalid_value_leaves_default() {
33043        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
33044        let mut cfg = sloc_config::AppConfig::default();
33045        let mut form = blank_form();
33046        form.style_col_threshold = Some("42".to_string());
33047        apply_form_to_config(&mut cfg, &form);
33048        assert_eq!(
33049            cfg.analysis.style_col_threshold, 80,
33050            "invalid threshold must not change config"
33051        );
33052    }
33053
33054    #[test]
33055    fn style_threshold_non_numeric_leaves_default() {
33056        let mut cfg = sloc_config::AppConfig::default();
33057        let mut form = blank_form();
33058        form.style_col_threshold = Some("large".to_string());
33059        apply_form_to_config(&mut cfg, &form);
33060        assert_eq!(cfg.analysis.style_col_threshold, 80);
33061    }
33062
33063    #[test]
33064    fn style_threshold_zero_leaves_default() {
33065        let mut cfg = sloc_config::AppConfig::default();
33066        let mut form = blank_form();
33067        form.style_col_threshold = Some("0".to_string());
33068        apply_form_to_config(&mut cfg, &form);
33069        assert_eq!(cfg.analysis.style_col_threshold, 80);
33070    }
33071
33072    #[test]
33073    fn style_threshold_absent_leaves_default() {
33074        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33075    }
33076
33077    // ── style_score_threshold ──
33078
33079    #[test]
33080    fn style_score_threshold_zero_when_absent() {
33081        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33082    }
33083
33084    #[test]
33085    fn style_score_threshold_set_to_valid_value() {
33086        let mut form = blank_form();
33087        form.style_score_threshold = Some("70".to_string());
33088        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33089    }
33090
33091    #[test]
33092    fn style_score_threshold_clamps_to_100_when_over() {
33093        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
33094        let mut form = blank_form();
33095        form.style_score_threshold = Some("200".to_string());
33096        assert_eq!(
33097            apply(&form).analysis.style_score_threshold,
33098            100,
33099            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33100        );
33101    }
33102
33103    // ── coverage_file ──
33104
33105    #[test]
33106    fn coverage_file_none_when_absent() {
33107        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33108    }
33109
33110    #[test]
33111    fn coverage_file_none_when_whitespace_only() {
33112        let mut form = blank_form();
33113        form.coverage_file = Some("   ".to_string());
33114        assert!(
33115            apply(&form).analysis.coverage_file.is_none(),
33116            "whitespace-only coverage_file must be treated as None"
33117        );
33118    }
33119
33120    #[test]
33121    fn coverage_file_set_when_non_empty() {
33122        let mut form = blank_form();
33123        form.coverage_file = Some("coverage/lcov.info".to_string());
33124        assert_eq!(
33125            apply(&form).analysis.coverage_file,
33126            Some(std::path::PathBuf::from("coverage/lcov.info"))
33127        );
33128    }
33129
33130    #[test]
33131    fn coverage_file_trims_whitespace() {
33132        let mut form = blank_form();
33133        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
33134        assert_eq!(
33135            apply(&form).analysis.coverage_file,
33136            Some(std::path::PathBuf::from("coverage/lcov.info"))
33137        );
33138    }
33139
33140    // ── report_title ──
33141
33142    #[test]
33143    fn report_title_unchanged_when_absent() {
33144        let original = sloc_config::AppConfig::default().reporting.report_title;
33145        assert_eq!(apply(&blank_form()).reporting.report_title, original);
33146    }
33147
33148    #[test]
33149    fn report_title_unchanged_when_whitespace_only() {
33150        let original = sloc_config::AppConfig::default().reporting.report_title;
33151        let mut form = blank_form();
33152        form.report_title = Some("   ".to_string());
33153        assert_eq!(
33154            apply(&form).reporting.report_title,
33155            original,
33156            "whitespace-only title must not overwrite the default"
33157        );
33158    }
33159
33160    #[test]
33161    fn report_title_updated_and_trimmed() {
33162        let mut form = blank_form();
33163        form.report_title = Some("  My Project  ".to_string());
33164        assert_eq!(apply(&form).reporting.report_title, "My Project");
33165    }
33166
33167    // ── report_header_footer ──
33168
33169    #[test]
33170    fn header_footer_none_when_absent() {
33171        assert!(apply(&blank_form())
33172            .reporting
33173            .report_header_footer
33174            .is_none());
33175    }
33176
33177    #[test]
33178    fn header_footer_none_when_whitespace_only() {
33179        let mut form = blank_form();
33180        form.report_header_footer = Some("  ".to_string());
33181        assert!(apply(&form).reporting.report_header_footer.is_none());
33182    }
33183
33184    #[test]
33185    fn header_footer_set_and_trimmed() {
33186        let mut form = blank_form();
33187        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
33188        assert_eq!(
33189            apply(&form).reporting.report_header_footer,
33190            Some("Confidential — Internal Use".to_string())
33191        );
33192    }
33193
33194    // ── include_globs / exclude_globs ──
33195
33196    #[test]
33197    fn include_globs_empty_when_absent() {
33198        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33199    }
33200
33201    #[test]
33202    fn include_globs_newline_separated() {
33203        let mut form = blank_form();
33204        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33205        assert_eq!(
33206            apply(&form).discovery.include_globs,
33207            vec!["src/**/*.rs", "tests/**/*.rs"]
33208        );
33209    }
33210
33211    #[test]
33212    fn exclude_globs_comma_separated() {
33213        let mut form = blank_form();
33214        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33215        assert_eq!(
33216            apply(&form).discovery.exclude_globs,
33217            vec!["vendor/**", "node_modules/**"]
33218        );
33219    }
33220
33221    #[test]
33222    fn globs_mixed_separators() {
33223        let mut form = blank_form();
33224        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33225        assert_eq!(
33226            apply(&form).discovery.exclude_globs,
33227            vec!["a/**", "b/**", "c/**"]
33228        );
33229    }
33230
33231    // ── split_patterns unit tests ──
33232
33233    #[test]
33234    fn split_patterns_none_is_empty() {
33235        assert!(split_patterns(None).is_empty());
33236    }
33237
33238    #[test]
33239    fn split_patterns_empty_string_is_empty() {
33240        assert!(split_patterns(Some("")).is_empty());
33241    }
33242
33243    #[test]
33244    fn split_patterns_whitespace_only_is_empty() {
33245        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
33246    }
33247
33248    #[test]
33249    fn split_patterns_newlines() {
33250        assert_eq!(
33251            split_patterns(Some("a/**\nb/**\nc/**")),
33252            vec!["a/**", "b/**", "c/**"]
33253        );
33254    }
33255
33256    #[test]
33257    fn split_patterns_commas() {
33258        assert_eq!(
33259            split_patterns(Some("a/**,b/**,c/**")),
33260            vec!["a/**", "b/**", "c/**"]
33261        );
33262    }
33263
33264    #[test]
33265    fn split_patterns_mixed() {
33266        assert_eq!(
33267            split_patterns(Some("a/**\nb/**,c/**")),
33268            vec!["a/**", "b/**", "c/**"]
33269        );
33270    }
33271
33272    #[test]
33273    fn split_patterns_trims_whitespace() {
33274        assert_eq!(
33275            split_patterns(Some("  a/**  \n  b/**  ")),
33276            vec!["a/**", "b/**"]
33277        );
33278    }
33279
33280    #[test]
33281    fn split_patterns_filters_empty_entries() {
33282        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33283    }
33284
33285    #[test]
33286    fn split_patterns_single_entry() {
33287        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33288    }
33289}
33290
33291#[cfg(test)]
33292mod utility_tests {
33293    use super::*;
33294    use std::net::IpAddr;
33295    use std::time::Duration;
33296
33297    // ── sanitize_project_label ────────────────────────────────────────────────
33298
33299    #[test]
33300    fn sanitize_simple_name() {
33301        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33302    }
33303
33304    #[test]
33305    fn sanitize_uppercased_lowercased() {
33306        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33307    }
33308
33309    #[test]
33310    fn sanitize_path_extracts_filename() {
33311        assert_eq!(
33312            sanitize_project_label("/home/user/my-project"),
33313            "my-project"
33314        );
33315    }
33316
33317    #[test]
33318    fn sanitize_path_uses_last_component() {
33319        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33320    }
33321
33322    #[test]
33323    fn sanitize_spaces_become_hyphens() {
33324        assert_eq!(sanitize_project_label("my project"), "my-project");
33325    }
33326
33327    #[test]
33328    fn sanitize_non_ascii_become_hyphens() {
33329        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33330    }
33331
33332    #[test]
33333    fn sanitize_all_special_chars_gives_project() {
33334        assert_eq!(sanitize_project_label("!@#$%^"), "project");
33335    }
33336
33337    #[test]
33338    fn sanitize_empty_string_gives_project() {
33339        assert_eq!(sanitize_project_label(""), "project");
33340    }
33341
33342    #[test]
33343    fn sanitize_leading_trailing_hyphens_stripped() {
33344        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33345    }
33346
33347    #[test]
33348    fn sanitize_alphanumeric_preserved() {
33349        assert_eq!(sanitize_project_label("repo123"), "repo123");
33350    }
33351
33352    #[test]
33353    fn sanitize_dots_become_hyphens() {
33354        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33355    }
33356
33357    #[test]
33358    fn sanitize_mixed_slashes_uses_filename() {
33359        // The Windows path separator — on all platforms Path::file_name still works
33360        assert_eq!(sanitize_project_label("project-name"), "project-name");
33361    }
33362
33363    // ── IpRateLimiter ─────────────────────────────────────────────────────────
33364
33365    #[test]
33366    fn rate_limiter_allows_first_request() {
33367        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33368        let ip: IpAddr = "127.0.0.1".parse().unwrap();
33369        assert!(rl.is_allowed(ip));
33370    }
33371
33372    #[test]
33373    fn rate_limiter_blocks_after_limit_reached() {
33374        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33375        let ip: IpAddr = "10.0.0.1".parse().unwrap();
33376        assert!(rl.is_allowed(ip));
33377        assert!(rl.is_allowed(ip));
33378        assert!(rl.is_allowed(ip));
33379        assert!(!rl.is_allowed(ip), "4th request must be blocked");
33380    }
33381
33382    #[test]
33383    fn rate_limiter_allows_requests_up_to_limit() {
33384        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33385        let ip: IpAddr = "10.0.0.2".parse().unwrap();
33386        for _ in 0..5 {
33387            assert!(rl.is_allowed(ip));
33388        }
33389        assert!(!rl.is_allowed(ip), "6th request must be blocked");
33390    }
33391
33392    #[test]
33393    fn rate_limiter_different_ips_are_independent() {
33394        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33395        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33396        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33397        assert!(rl.is_allowed(ip1));
33398        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33399        assert!(rl.is_allowed(ip2), "ip2 must be independent");
33400    }
33401
33402    #[test]
33403    fn rate_limiter_auth_failure_not_locked_below_threshold() {
33404        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33405        let ip: IpAddr = "10.0.0.3".parse().unwrap();
33406        rl.record_auth_failure(ip);
33407        rl.record_auth_failure(ip);
33408        assert!(
33409            !rl.is_auth_locked_out(ip),
33410            "not locked at 2 failures when threshold is 3"
33411        );
33412    }
33413
33414    #[test]
33415    fn rate_limiter_auth_failure_locked_at_threshold() {
33416        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33417        let ip: IpAddr = "10.0.0.4".parse().unwrap();
33418        rl.record_auth_failure(ip);
33419        rl.record_auth_failure(ip);
33420        rl.record_auth_failure(ip);
33421        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33422    }
33423
33424    #[test]
33425    fn rate_limiter_auth_failure_different_ips_independent() {
33426        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33427        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33428        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33429        rl.record_auth_failure(ip1);
33430        rl.record_auth_failure(ip1);
33431        assert!(rl.is_auth_locked_out(ip1));
33432        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33433    }
33434
33435    #[test]
33436    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33437        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33438        let ip: IpAddr = "127.0.0.2".parse().unwrap();
33439        for _ in 0..100 {
33440            assert!(rl.is_allowed(ip));
33441        }
33442    }
33443
33444    // ── strip_unc_prefix ──────────────────────────────────────────────────────
33445
33446    #[test]
33447    fn strip_unc_plain_path_unchanged() {
33448        let p = PathBuf::from("C:\\Users\\user\\project");
33449        let result = strip_unc_prefix(p.clone());
33450        assert_eq!(result, p);
33451    }
33452
33453    #[test]
33454    fn strip_unc_with_drive_prefix_stripped() {
33455        let p = PathBuf::from(r"\\?\C:\Users\user\project");
33456        let result = strip_unc_prefix(p);
33457        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33458    }
33459
33460    #[test]
33461    fn strip_unc_with_network_prefix_stripped() {
33462        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33463        let result = strip_unc_prefix(p);
33464        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33465    }
33466
33467    #[test]
33468    fn strip_unc_linux_path_unchanged() {
33469        let p = PathBuf::from("/home/user/project");
33470        let result = strip_unc_prefix(p.clone());
33471        assert_eq!(result, p);
33472    }
33473
33474    // ── remote_to_commit_url ──────────────────────────────────────────────────
33475
33476    #[test]
33477    fn remote_to_commit_url_github_https() {
33478        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33479        assert_eq!(
33480            url,
33481            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33482        );
33483    }
33484
33485    #[test]
33486    fn remote_to_commit_url_github_ssh() {
33487        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33488        assert_eq!(
33489            url,
33490            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33491        );
33492    }
33493
33494    #[test]
33495    fn remote_to_commit_url_gitlab_uses_dash_commit() {
33496        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33497        assert_eq!(
33498            url,
33499            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33500        );
33501    }
33502
33503    #[test]
33504    fn remote_to_commit_url_bitbucket_uses_commits() {
33505        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33506        assert_eq!(
33507            url,
33508            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33509        );
33510    }
33511
33512    #[test]
33513    fn remote_to_commit_url_unknown_scheme_returns_none() {
33514        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33515        assert!(url.is_none());
33516    }
33517
33518    #[test]
33519    fn remote_to_commit_url_ssh_gitlab() {
33520        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33521        assert!(url.is_some());
33522        let u = url.unwrap();
33523        assert!(
33524            u.contains("/-/commit/sha123"),
33525            "gitlab ssh must use /-/commit/"
33526        );
33527    }
33528
33529    // ── git_clone_dest ────────────────────────────────────────────────────────
33530
33531    #[test]
33532    fn git_clone_dest_github_url_produces_safe_name() {
33533        let dir = PathBuf::from("/tmp/clones");
33534        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33535        let name = dest.file_name().unwrap().to_string_lossy();
33536        assert!(!name.is_empty());
33537        assert!(
33538            name.chars()
33539                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33540            "clone dest must only contain safe chars, got: {name}"
33541        );
33542    }
33543
33544    #[test]
33545    fn git_clone_dest_is_inside_clones_dir() {
33546        let dir = PathBuf::from("/tmp/clones");
33547        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33548        assert!(
33549            dest.starts_with(&dir),
33550            "clone dest must be inside clones_dir"
33551        );
33552    }
33553
33554    #[test]
33555    fn git_clone_dest_truncates_to_80_chars_max() {
33556        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33557        let dir = PathBuf::from("/tmp/clones");
33558        let dest = git_clone_dest(&long_url, &dir);
33559        let name = dest.file_name().unwrap().to_string_lossy();
33560        assert!(
33561            name.len() <= 80,
33562            "clone dest name must be at most 80 chars, got {} chars: {name}",
33563            name.len()
33564        );
33565    }
33566
33567    #[test]
33568    fn git_clone_dest_special_chars_replaced_with_underscore() {
33569        let dir = PathBuf::from("/tmp/clones");
33570        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33571        let name = dest.file_name().unwrap().to_string_lossy();
33572        assert!(
33573            !name.contains('@') && !name.contains(':') && !name.contains('/'),
33574            "special chars must be replaced in clone dest, got: {name}"
33575        );
33576    }
33577
33578    #[test]
33579    fn git_clone_dest_different_urls_differ() {
33580        let dir = PathBuf::from("/tmp/clones");
33581        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33582        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33583        assert_ne!(
33584            a, b,
33585            "different repos must produce different clone dest names"
33586        );
33587    }
33588
33589    #[test]
33590    fn git_clone_dest_same_url_same_result() {
33591        let dir = PathBuf::from("/tmp/clones");
33592        let url = "https://github.com/owner/repo.git";
33593        assert_eq!(
33594            git_clone_dest(url, &dir),
33595            git_clone_dest(url, &dir),
33596            "same URL must always give same clone dest"
33597        );
33598    }
33599
33600    // ── fmt_delta ─────────────────────────────────────────────────────────────
33601
33602    #[test]
33603    fn fmt_delta_positive_has_plus_prefix() {
33604        assert_eq!(fmt_delta(5), "+5");
33605    }
33606
33607    #[test]
33608    fn fmt_delta_negative_no_plus_prefix() {
33609        assert_eq!(fmt_delta(-3), "-3");
33610    }
33611
33612    #[test]
33613    fn fmt_delta_zero() {
33614        assert_eq!(fmt_delta(0), "0");
33615    }
33616
33617    // ── delta_class ───────────────────────────────────────────────────────────
33618
33619    #[test]
33620    fn delta_class_positive_is_pos() {
33621        assert_eq!(delta_class(1), "pos");
33622    }
33623
33624    #[test]
33625    fn delta_class_negative_is_neg() {
33626        assert_eq!(delta_class(-1), "neg");
33627    }
33628
33629    #[test]
33630    fn delta_class_zero_is_zero_class() {
33631        assert_eq!(delta_class(0), "zero");
33632    }
33633
33634    // ── fmt_pct ───────────────────────────────────────────────────────────────
33635
33636    #[test]
33637    fn fmt_pct_zero_baseline_returns_em_dash() {
33638        assert_eq!(fmt_pct(100, 0), "\u{2014}");
33639    }
33640
33641    #[test]
33642    fn fmt_pct_positive_delta_has_plus_sign() {
33643        let result = fmt_pct(10, 100);
33644        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33645    }
33646
33647    #[test]
33648    fn fmt_pct_negative_delta_no_plus_sign() {
33649        let result = fmt_pct(-10, 100);
33650        assert!(!result.starts_with('+'), "unexpected + in: {result}");
33651        assert!(result.contains('%'));
33652    }
33653
33654    #[test]
33655    fn fmt_pct_near_zero_returns_pm_zero() {
33656        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33657    }
33658
33659    // ── summary_delta ─────────────────────────────────────────────────────────
33660
33661    #[test]
33662    fn summary_delta_no_prev_returns_dash_na() {
33663        let (display, class) = summary_delta(10, None);
33664        assert_eq!(display, "\u{2014}");
33665        assert_eq!(class, "na");
33666    }
33667
33668    #[test]
33669    fn summary_delta_increase_is_positive() {
33670        let (display, class) = summary_delta(15, Some(10));
33671        assert_eq!(display, "+5");
33672        assert_eq!(class, "pos");
33673    }
33674
33675    #[test]
33676    fn summary_delta_decrease_is_negative() {
33677        let (display, class) = summary_delta(5, Some(10));
33678        assert_eq!(display, "-5");
33679        assert_eq!(class, "neg");
33680    }
33681
33682    // ── nth_weekday_of_month ──────────────────────────────────────────────────
33683
33684    #[test]
33685    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33686        use chrono::Datelike;
33687        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33688        assert_eq!(d.year(), 2024);
33689        assert_eq!(d.month(), 1);
33690        assert_eq!(d.weekday(), chrono::Weekday::Mon);
33691        assert!(d.day() <= 7);
33692    }
33693
33694    #[test]
33695    fn nth_weekday_second_sunday_march_2024_is_10th() {
33696        use chrono::Datelike;
33697        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33698        assert_eq!(d.weekday(), chrono::Weekday::Sun);
33699        assert_eq!(d.month(), 3);
33700        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33701    }
33702
33703    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
33704
33705    #[test]
33706    fn is_pacific_dst_july_is_true() {
33707        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33708        assert!(is_pacific_dst(dt), "July must be PDT");
33709    }
33710
33711    #[test]
33712    fn is_pacific_dst_january_is_false() {
33713        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33714        assert!(!is_pacific_dst(dt), "January must be PST");
33715    }
33716
33717    #[test]
33718    fn fmt_la_time_summer_shows_pdt() {
33719        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33720        let result = fmt_la_time(dt);
33721        assert!(
33722            result.ends_with("PDT"),
33723            "summer must use PDT, got: {result}"
33724        );
33725    }
33726
33727    #[test]
33728    fn fmt_la_time_winter_shows_pst() {
33729        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33730        let result = fmt_la_time(dt);
33731        assert!(
33732            result.ends_with("PST"),
33733            "winter must use PST, got: {result}"
33734        );
33735    }
33736
33737    #[test]
33738    fn fmt_la_time_meta_summer_shows_pdt() {
33739        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33740        let result = fmt_la_time_meta(dt);
33741        assert!(
33742            result.ends_with("PDT"),
33743            "meta summer must use PDT, got: {result}"
33744        );
33745    }
33746
33747    #[test]
33748    fn fmt_la_time_meta_winter_shows_pst() {
33749        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33750        let result = fmt_la_time_meta(dt);
33751        assert!(
33752            result.ends_with("PST"),
33753            "meta winter must use PST, got: {result}"
33754        );
33755    }
33756
33757    // ── fmt_git_date ──────────────────────────────────────────────────────────
33758
33759    #[test]
33760    fn fmt_git_date_valid_iso_returns_some() {
33761        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33762    }
33763
33764    #[test]
33765    fn fmt_git_date_invalid_returns_none() {
33766        assert!(fmt_git_date("not-a-date").is_none());
33767    }
33768
33769    // ── format_number ─────────────────────────────────────────────────────────
33770
33771    #[test]
33772    fn format_number_zero() {
33773        assert_eq!(format_number(0), "0");
33774    }
33775
33776    #[test]
33777    fn format_number_three_digits_no_comma() {
33778        assert_eq!(format_number(999), "999");
33779    }
33780
33781    #[test]
33782    fn format_number_four_digits_has_comma() {
33783        assert_eq!(format_number(1000), "1,000");
33784    }
33785
33786    #[test]
33787    fn format_number_seven_digits_two_commas() {
33788        assert_eq!(format_number(1_234_567), "1,234,567");
33789    }
33790
33791    #[test]
33792    fn format_number_one_million() {
33793        assert_eq!(format_number(1_000_000), "1,000,000");
33794    }
33795
33796    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
33797
33798    #[test]
33799    fn badge_text_px_empty_is_zero() {
33800        assert_eq!(badge_text_px(""), 0);
33801    }
33802
33803    #[test]
33804    fn badge_text_px_narrow_chars_smaller_than_normal() {
33805        assert!(
33806            badge_text_px("if") < badge_text_px("ab"),
33807            "'if' must be narrower than 'ab'"
33808        );
33809    }
33810
33811    #[test]
33812    fn badge_text_px_m_is_wider_than_a() {
33813        assert!(
33814            badge_text_px("m") > badge_text_px("a"),
33815            "'m' must be wider than 'a'"
33816        );
33817    }
33818
33819    #[test]
33820    fn render_badge_svg_contains_label_and_value() {
33821        let svg = render_badge_svg("coverage", "95%", "#4c1");
33822        assert!(svg.contains("coverage") && svg.contains("95%"));
33823    }
33824
33825    #[test]
33826    fn render_badge_svg_contains_color() {
33827        let svg = render_badge_svg("sloc", "12K", "#e05d44");
33828        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33829    }
33830
33831    #[test]
33832    fn render_badge_svg_escapes_ampersand_in_label() {
33833        let svg = render_badge_svg("test&label", "ok", "#4c1");
33834        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
33835    }
33836
33837    // ── build_pdf_filename ────────────────────────────────────────────────────
33838
33839    #[test]
33840    fn build_pdf_filename_slugifies_title() {
33841        let name = build_pdf_filename("My Project Report", "abc-def-1234");
33842        assert!(
33843            name.starts_with("my_project_report_")
33844                && std::path::Path::new(&name)
33845                    .extension()
33846                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33847        );
33848    }
33849
33850    #[test]
33851    fn build_pdf_filename_uses_last_run_id_segment() {
33852        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33853        assert!(name.contains("ABCD"), "must use last segment of run_id");
33854    }
33855
33856    #[test]
33857    fn build_pdf_filename_empty_title_uses_report_prefix() {
33858        let name = build_pdf_filename("", "abc-def-9999");
33859        assert!(
33860            name.starts_with("report_")
33861                && std::path::Path::new(&name)
33862                    .extension()
33863                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33864        );
33865    }
33866
33867    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
33868
33869    #[test]
33870    fn swap_chart_js_replaces_inline_block() {
33871        let html = "<html><head><script>// inline source</script></head><body></body></html>";
33872        let result = swap_inline_chart_js_for_static(html.to_string());
33873        assert!(result.contains(r#"src="/static/chart-report.js""#));
33874        assert!(!result.contains("inline source"));
33875    }
33876
33877    #[test]
33878    fn swap_chart_js_no_head_returns_unchanged() {
33879        let html = "<body>no head here</body>";
33880        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33881    }
33882
33883    #[test]
33884    fn swap_chart_js_no_script_in_head_unchanged() {
33885        let html = "<html><head><style>.x{}</style></head><body></body></html>";
33886        let result = swap_inline_chart_js_for_static(html.to_string());
33887        assert!(!result.contains("chart-report.js"));
33888    }
33889
33890    // ── patch_html_nonce ──────────────────────────────────────────────────────
33891
33892    #[test]
33893    fn patch_html_nonce_replaces_old_nonce() {
33894        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33895        let result = patch_html_nonce(html, "new-nonce-456");
33896        assert!(result.contains(r#"nonce="new-nonce-456""#));
33897        assert!(!result.contains("old-nonce-123"));
33898    }
33899
33900    #[test]
33901    fn patch_html_nonce_injects_into_bare_style() {
33902        let html = "<style>body{color:red;}</style>";
33903        let result = patch_html_nonce(html, "fresh-nonce");
33904        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33905    }
33906
33907    #[test]
33908    fn patch_html_nonce_injects_into_bare_script() {
33909        let html = "<script>console.log(1);</script>";
33910        let result = patch_html_nonce(html, "abc");
33911        assert!(result.contains(r#"<script nonce="abc">"#));
33912    }
33913
33914    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
33915
33916    #[test]
33917    fn is_html_report_file_result_html_matches() {
33918        let dir = tempfile::tempdir().unwrap();
33919        let path = dir.path().join("result_20240101.html");
33920        std::fs::write(&path, b"<html></html>").unwrap();
33921        assert!(is_html_report_file(&path));
33922    }
33923
33924    #[test]
33925    fn is_html_report_file_report_html_matches() {
33926        let dir = tempfile::tempdir().unwrap();
33927        let path = dir.path().join("report_abc.html");
33928        std::fs::write(&path, b"<html></html>").unwrap();
33929        assert!(is_html_report_file(&path));
33930    }
33931
33932    #[test]
33933    fn is_html_report_file_index_html_does_not_match() {
33934        let dir = tempfile::tempdir().unwrap();
33935        let path = dir.path().join("index.html");
33936        std::fs::write(&path, b"<html></html>").unwrap();
33937        assert!(!is_html_report_file(&path));
33938    }
33939
33940    #[test]
33941    fn is_html_report_file_nonexistent_returns_false() {
33942        assert!(!is_html_report_file(Path::new(
33943            "/nonexistent/result_xyz.html"
33944        )));
33945    }
33946
33947    #[test]
33948    fn find_html_report_in_dir_finds_result_html() {
33949        let dir = tempfile::tempdir().unwrap();
33950        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33951        assert!(find_html_report_in_dir(dir.path()).is_some());
33952    }
33953
33954    #[test]
33955    fn find_html_report_in_dir_empty_returns_none() {
33956        let dir = tempfile::tempdir().unwrap();
33957        assert!(find_html_report_in_dir(dir.path()).is_none());
33958    }
33959
33960    #[test]
33961    fn find_html_report_in_tree_finds_in_subdir() {
33962        let dir = tempfile::tempdir().unwrap();
33963        let subdir = dir.path().join("run-001");
33964        std::fs::create_dir_all(&subdir).unwrap();
33965        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33966        assert!(find_html_report_in_tree(dir.path()).is_some());
33967    }
33968
33969    // ── derive_project_label ──────────────────────────────────────────────────
33970
33971    #[test]
33972    fn derive_project_label_with_git_repo_and_ref() {
33973        let label = derive_project_label(
33974            Some("https://github.com/owner/my-repo.git"),
33975            Some("main"),
33976            "/fallback/path",
33977        );
33978        assert!(!label.is_empty(), "label must not be empty");
33979        assert!(
33980            label.contains("my") || label.contains("repo"),
33981            "got: {label}"
33982        );
33983    }
33984
33985    #[test]
33986    fn derive_project_label_fallback_to_path() {
33987        let label = derive_project_label(None, None, "/path/to/myproject");
33988        assert_eq!(label, "myproject");
33989    }
33990
33991    #[test]
33992    fn derive_project_label_empty_git_fields_use_path() {
33993        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33994        assert_eq!(label, "cool-app");
33995    }
33996
33997    // ── derive_file_stem ──────────────────────────────────────────────────────
33998
33999    #[test]
34000    fn derive_file_stem_with_commit_appends_sha() {
34001        assert_eq!(
34002            derive_file_stem("myproject", Some("a1b2c3")),
34003            "myproject_a1b2c3"
34004        );
34005    }
34006
34007    #[test]
34008    fn derive_file_stem_without_commit_returns_label() {
34009        assert_eq!(derive_file_stem("myproject", None), "myproject");
34010    }
34011
34012    #[test]
34013    fn derive_file_stem_empty_commit_returns_label() {
34014        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34015    }
34016
34017    // ── split_patterns ────────────────────────────────────────────────────────
34018
34019    #[test]
34020    fn split_patterns_none_is_empty() {
34021        assert!(split_patterns(None).is_empty());
34022    }
34023
34024    #[test]
34025    fn split_patterns_empty_string_is_empty() {
34026        assert!(split_patterns(Some("")).is_empty());
34027    }
34028
34029    #[test]
34030    fn split_patterns_comma_separated() {
34031        assert_eq!(
34032            split_patterns(Some("foo,bar,baz")),
34033            vec!["foo", "bar", "baz"]
34034        );
34035    }
34036
34037    #[test]
34038    fn split_patterns_newline_separated() {
34039        assert_eq!(
34040            split_patterns(Some("foo\nbar\nbaz")),
34041            vec!["foo", "bar", "baz"]
34042        );
34043    }
34044
34045    #[test]
34046    fn split_patterns_trims_whitespace() {
34047        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
34048    }
34049
34050    // ── make_git_label ────────────────────────────────────────────────────────
34051
34052    #[test]
34053    fn make_git_label_empty_repo_empty_result() {
34054        assert_eq!(make_git_label("", "main"), "");
34055    }
34056
34057    #[test]
34058    fn make_git_label_empty_ref_empty_result() {
34059        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34060    }
34061
34062    #[test]
34063    fn make_git_label_basic_format() {
34064        assert_eq!(
34065            make_git_label("https://github.com/owner/my-repo.git", "main"),
34066            "my-repo_at_main_sloc"
34067        );
34068    }
34069
34070    #[test]
34071    fn make_git_label_slash_in_ref_replaced() {
34072        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34073        assert!(
34074            !label.contains('/'),
34075            "slash in ref must be replaced: {label}"
34076        );
34077    }
34078
34079    // ── format_dir_size ───────────────────────────────────────────────────────
34080
34081    #[test]
34082    fn format_dir_size_bytes() {
34083        assert_eq!(format_dir_size(500), "500 B");
34084    }
34085
34086    #[test]
34087    fn format_dir_size_kilobytes() {
34088        assert_eq!(format_dir_size(2048), "2 KB");
34089    }
34090
34091    #[test]
34092    fn format_dir_size_megabytes() {
34093        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34094    }
34095
34096    #[test]
34097    fn format_dir_size_gigabytes() {
34098        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34099    }
34100
34101    #[test]
34102    fn format_dir_size_zero() {
34103        assert_eq!(format_dir_size(0), "0 B");
34104    }
34105
34106    // ── civil_from_days ───────────────────────────────────────────────────────
34107
34108    #[test]
34109    fn civil_from_days_epoch() {
34110        assert_eq!(civil_from_days(0), (1970, 1, 1));
34111    }
34112
34113    #[test]
34114    fn civil_from_days_one_year_later() {
34115        assert_eq!(civil_from_days(365), (1971, 1, 1));
34116    }
34117
34118    #[test]
34119    fn civil_from_days_31_days_is_feb_1_1970() {
34120        assert_eq!(civil_from_days(31), (1970, 2, 1));
34121    }
34122
34123    // ── format_system_time ────────────────────────────────────────────────────
34124
34125    #[test]
34126    fn format_system_time_unix_epoch_formats_correctly() {
34127        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34128    }
34129
34130    #[test]
34131    fn format_system_time_31_days_after_epoch() {
34132        let t = UNIX_EPOCH + Duration::from_hours(744);
34133        assert_eq!(format_system_time(t), "1970-02-01 00:00");
34134    }
34135
34136    #[test]
34137    fn format_system_time_before_epoch_returns_dash() {
34138        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34139            assert_eq!(format_system_time(before), "-");
34140        }
34141    }
34142
34143    // ── detect_language_name ──────────────────────────────────────────────────
34144
34145    #[test]
34146    fn detect_language_name_dot_c() {
34147        assert_eq!(detect_language_name("main.c"), Some("C"));
34148    }
34149
34150    #[test]
34151    fn detect_language_name_dot_h() {
34152        assert_eq!(detect_language_name("defs.h"), Some("C"));
34153    }
34154
34155    #[test]
34156    fn detect_language_name_dot_cpp() {
34157        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34158    }
34159
34160    #[test]
34161    fn detect_language_name_dot_py() {
34162        assert_eq!(detect_language_name("script.py"), Some("Python"));
34163    }
34164
34165    #[test]
34166    fn detect_language_name_dot_ps1() {
34167        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34168    }
34169
34170    #[test]
34171    fn detect_language_name_dot_cs() {
34172        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34173    }
34174
34175    #[test]
34176    fn detect_language_name_dot_sh() {
34177        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34178    }
34179
34180    #[test]
34181    fn detect_language_name_unknown_txt() {
34182        assert_eq!(detect_language_name("notes.txt"), None);
34183    }
34184
34185    // ── language_icon_file ────────────────────────────────────────────────────
34186
34187    #[test]
34188    fn language_icon_file_c() {
34189        assert_eq!(language_icon_file("C"), Some("c.png"));
34190    }
34191
34192    #[test]
34193    fn language_icon_file_python() {
34194        assert_eq!(language_icon_file("Python"), Some("python.png"));
34195    }
34196
34197    #[test]
34198    fn language_icon_file_dockerfile() {
34199        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34200    }
34201
34202    #[test]
34203    fn language_icon_file_rust_is_none() {
34204        assert!(language_icon_file("Rust").is_none());
34205    }
34206
34207    #[test]
34208    fn language_icon_file_unknown_is_none() {
34209        assert!(language_icon_file("Fortran").is_none());
34210    }
34211
34212    // ── language_inline_svg ───────────────────────────────────────────────────
34213
34214    #[test]
34215    fn language_inline_svg_rust_is_svg() {
34216        let svg = language_inline_svg("Rust").unwrap();
34217        assert!(svg.starts_with("<svg"));
34218    }
34219
34220    #[test]
34221    fn language_inline_svg_typescript_is_some() {
34222        assert!(language_inline_svg("TypeScript").is_some());
34223    }
34224
34225    #[test]
34226    fn language_inline_svg_unknown_is_none() {
34227        assert!(language_inline_svg("Fortran").is_none());
34228    }
34229
34230    // ── classify_preview_file ─────────────────────────────────────────────────
34231
34232    #[test]
34233    fn classify_preview_file_c_supported() {
34234        assert!(matches!(
34235            classify_preview_file("main.c"),
34236            PreviewKind::Supported
34237        ));
34238    }
34239
34240    #[test]
34241    fn classify_preview_file_python_supported() {
34242        assert!(matches!(
34243            classify_preview_file("script.py"),
34244            PreviewKind::Supported
34245        ));
34246    }
34247
34248    #[test]
34249    fn classify_preview_file_png_skipped() {
34250        assert!(matches!(
34251            classify_preview_file("image.png"),
34252            PreviewKind::Skipped
34253        ));
34254    }
34255
34256    #[test]
34257    fn classify_preview_file_zip_skipped() {
34258        assert!(matches!(
34259            classify_preview_file("archive.zip"),
34260            PreviewKind::Skipped
34261        ));
34262    }
34263
34264    #[test]
34265    fn classify_preview_file_min_js_skipped() {
34266        assert!(matches!(
34267            classify_preview_file("bundle.min.js"),
34268            PreviewKind::Skipped
34269        ));
34270    }
34271
34272    #[test]
34273    fn classify_preview_file_rs_unsupported() {
34274        assert!(matches!(
34275            classify_preview_file("main.rs"),
34276            PreviewKind::Unsupported
34277        ));
34278    }
34279
34280    // ── preview_relative_path ─────────────────────────────────────────────────
34281
34282    #[test]
34283    fn preview_relative_path_strips_root() {
34284        let root = PathBuf::from("/project");
34285        let path = PathBuf::from("/project/src/main.c");
34286        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34287    }
34288
34289    #[test]
34290    fn preview_relative_path_unrooted_includes_filename() {
34291        let root = PathBuf::from("/other");
34292        let path = PathBuf::from("/project/src/main.c");
34293        let result = preview_relative_path(&root, &path);
34294        assert!(result.contains("main.c"));
34295    }
34296
34297    #[test]
34298    fn preview_relative_path_uses_forward_slashes() {
34299        let root = PathBuf::from("/project");
34300        let path = PathBuf::from("/project/a/b/c.py");
34301        assert!(!preview_relative_path(&root, &path).contains('\\'));
34302    }
34303
34304    // ── wildcard_match ────────────────────────────────────────────────────────
34305
34306    #[test]
34307    fn wildcard_match_exact_equal() {
34308        assert!(wildcard_match("foo", "foo"));
34309    }
34310
34311    #[test]
34312    fn wildcard_match_exact_mismatch() {
34313        assert!(!wildcard_match("foo", "bar"));
34314    }
34315
34316    #[test]
34317    fn wildcard_match_star_suffix() {
34318        assert!(wildcard_match("*.rs", "main.rs"));
34319    }
34320
34321    #[test]
34322    fn wildcard_match_star_middle_requires_suffix() {
34323        assert!(!wildcard_match("a*b", "ac"));
34324    }
34325
34326    #[test]
34327    fn wildcard_match_question_mark_single_char() {
34328        assert!(wildcard_match("f?o", "foo"));
34329    }
34330
34331    #[test]
34332    fn wildcard_match_double_star_nested() {
34333        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34334    }
34335
34336    #[test]
34337    fn wildcard_match_star_directory_entry() {
34338        assert!(wildcard_match("vendor/*", "vendor/crate"));
34339    }
34340
34341    #[test]
34342    fn wildcard_match_no_cross_prefix() {
34343        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34344    }
34345
34346    // ── should_skip_preview_directory ────────────────────────────────────────
34347
34348    #[test]
34349    fn should_skip_empty_relative_is_false() {
34350        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34351    }
34352
34353    #[test]
34354    fn should_skip_matching_pattern() {
34355        assert!(should_skip_preview_directory(
34356            "vendor",
34357            &["vendor".to_string()]
34358        ));
34359    }
34360
34361    #[test]
34362    fn should_skip_non_matching() {
34363        assert!(!should_skip_preview_directory(
34364            "src",
34365            &["vendor".to_string()]
34366        ));
34367    }
34368
34369    #[test]
34370    fn should_skip_wildcard_prefix() {
34371        assert!(should_skip_preview_directory(
34372            "target/debug",
34373            &["target*".to_string()]
34374        ));
34375    }
34376
34377    // ── should_include_preview_file ───────────────────────────────────────────
34378
34379    #[test]
34380    fn should_include_empty_relative_always_true() {
34381        assert!(should_include_preview_file("", &[], &[]));
34382    }
34383
34384    #[test]
34385    fn should_include_no_patterns_includes_all() {
34386        assert!(should_include_preview_file("src/main.c", &[], &[]));
34387    }
34388
34389    #[test]
34390    fn should_include_excluded_by_pattern() {
34391        assert!(!should_include_preview_file(
34392            "vendor/lib.c",
34393            &[],
34394            &["vendor/*".to_string()]
34395        ));
34396    }
34397
34398    #[test]
34399    fn should_include_include_pattern_filters() {
34400        assert!(!should_include_preview_file(
34401            "tests/test_foo.c",
34402            &["src/*".to_string()],
34403            &[]
34404        ));
34405    }
34406
34407    // ── escape_html ───────────────────────────────────────────────────────────
34408
34409    #[test]
34410    fn escape_html_ampersand() {
34411        assert_eq!(escape_html("a&b"), "a&amp;b");
34412    }
34413
34414    #[test]
34415    fn escape_html_angle_brackets() {
34416        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
34417    }
34418
34419    #[test]
34420    fn escape_html_double_quote() {
34421        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
34422    }
34423
34424    #[test]
34425    fn escape_html_single_quote() {
34426        assert_eq!(escape_html("it's"), "it&#39;s");
34427    }
34428
34429    #[test]
34430    fn escape_html_plain_text_unchanged() {
34431        assert_eq!(escape_html("hello world"), "hello world");
34432    }
34433
34434    // ── sum_added / removed / unmodified code lines ───────────────────────────
34435
34436    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34437        sloc_core::ScanComparison {
34438            summary: sloc_core::SummaryDelta {
34439                baseline_run_id: "base".to_string(),
34440                current_run_id: "curr".to_string(),
34441                baseline_timestamp: chrono::Utc::now(),
34442                current_timestamp: chrono::Utc::now(),
34443                baseline_files: 4,
34444                current_files: 4,
34445                files_analyzed_delta: 0,
34446                baseline_code: 330,
34447                current_code: 400,
34448                code_lines_delta: 70,
34449                baseline_comments: 0,
34450                current_comments: 0,
34451                comment_lines_delta: 0,
34452                blank_lines_delta: 0,
34453                total_lines_delta: 70,
34454                coverage_lines_hit_delta: None,
34455                coverage_line_pct_delta: None,
34456                baseline_coverage_line_pct: None,
34457                current_coverage_line_pct: None,
34458            },
34459            file_deltas: vec![
34460                sloc_core::FileDelta {
34461                    relative_path: "added.rs".to_string(),
34462                    language: Some("Rust".to_string()),
34463                    status: FileChangeStatus::Added,
34464                    baseline_code: 0,
34465                    current_code: 100,
34466                    code_delta: 100,
34467                    baseline_comment: 0,
34468                    current_comment: 0,
34469                    comment_delta: 0,
34470                    baseline_blank: 0,
34471                    current_blank: 0,
34472                    blank_delta: 0,
34473                    total_delta: 100,
34474                },
34475                sloc_core::FileDelta {
34476                    relative_path: "removed.rs".to_string(),
34477                    language: Some("Rust".to_string()),
34478                    status: FileChangeStatus::Removed,
34479                    baseline_code: 50,
34480                    current_code: 0,
34481                    code_delta: -50,
34482                    baseline_comment: 0,
34483                    current_comment: 0,
34484                    comment_delta: 0,
34485                    baseline_blank: 0,
34486                    current_blank: 0,
34487                    blank_delta: 0,
34488                    total_delta: -50,
34489                },
34490                sloc_core::FileDelta {
34491                    relative_path: "modified.rs".to_string(),
34492                    language: Some("Rust".to_string()),
34493                    status: FileChangeStatus::Modified,
34494                    baseline_code: 80,
34495                    current_code: 100,
34496                    code_delta: 20,
34497                    baseline_comment: 0,
34498                    current_comment: 0,
34499                    comment_delta: 0,
34500                    baseline_blank: 0,
34501                    current_blank: 0,
34502                    blank_delta: 0,
34503                    total_delta: 20,
34504                },
34505                sloc_core::FileDelta {
34506                    relative_path: "unchanged.rs".to_string(),
34507                    language: Some("Rust".to_string()),
34508                    status: FileChangeStatus::Unchanged,
34509                    baseline_code: 200,
34510                    current_code: 200,
34511                    code_delta: 0,
34512                    baseline_comment: 0,
34513                    current_comment: 0,
34514                    comment_delta: 0,
34515                    baseline_blank: 0,
34516                    current_blank: 0,
34517                    blank_delta: 0,
34518                    total_delta: 0,
34519                },
34520            ],
34521            files_added: 1,
34522            files_removed: 1,
34523            files_modified: 1,
34524            files_unchanged: 1,
34525        }
34526    }
34527
34528    #[test]
34529    fn sum_added_counts_added_and_positive_modified() {
34530        let cmp = make_mixed_scan_comparison();
34531        assert_eq!(sum_added_code_lines(&cmp), 120);
34532    }
34533
34534    #[test]
34535    fn sum_removed_counts_removed_baseline() {
34536        let cmp = make_mixed_scan_comparison();
34537        assert_eq!(sum_removed_code_lines(&cmp), 50);
34538    }
34539
34540    #[test]
34541    fn sum_unmodified_counts_unchanged_files() {
34542        let cmp = make_mixed_scan_comparison();
34543        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34544    }
34545
34546    // ── detect_coverage_tool ──────────────────────────────────────────────────
34547
34548    #[test]
34549    fn detect_coverage_tool_rust_project() {
34550        let dir = tempfile::tempdir().unwrap();
34551        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34552        let (tool, cmd) = detect_coverage_tool(dir.path());
34553        assert_eq!(tool, Some("cargo-llvm-cov"));
34554        assert!(cmd.is_some());
34555    }
34556
34557    #[test]
34558    fn detect_coverage_tool_java_gradle() {
34559        let dir = tempfile::tempdir().unwrap();
34560        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34561        let (tool, _) = detect_coverage_tool(dir.path());
34562        assert_eq!(tool, Some("jacoco"));
34563    }
34564
34565    #[test]
34566    fn detect_coverage_tool_python_pyproject() {
34567        let dir = tempfile::tempdir().unwrap();
34568        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34569        let (tool, _) = detect_coverage_tool(dir.path());
34570        assert_eq!(tool, Some("pytest-cov"));
34571    }
34572
34573    #[test]
34574    fn detect_coverage_tool_unknown_project() {
34575        let dir = tempfile::tempdir().unwrap();
34576        let (tool, cmd) = detect_coverage_tool(dir.path());
34577        assert!(tool.is_none() && cmd.is_none());
34578    }
34579
34580    // ── sanitize_path_str / display_path ─────────────────────────────────────
34581
34582    #[test]
34583    fn sanitize_path_str_unc_drive_stripped() {
34584        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34585    }
34586
34587    #[test]
34588    fn sanitize_path_str_unc_network_stripped() {
34589        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34590    }
34591
34592    #[test]
34593    fn sanitize_path_str_plain_path_unchanged() {
34594        assert_eq!(
34595            sanitize_path_str("/home/user/project"),
34596            "/home/user/project"
34597        );
34598    }
34599
34600    #[test]
34601    fn display_path_plain_linux_unchanged() {
34602        assert_eq!(
34603            display_path(Path::new("/home/user/project")),
34604            "/home/user/project"
34605        );
34606    }
34607
34608    #[test]
34609    fn display_path_unc_drive_stripped() {
34610        let result = display_path(Path::new(r"\\?\C:\Users\user"));
34611        assert_eq!(result, r"C:\Users\user");
34612    }
34613
34614    #[test]
34615    fn display_path_unc_network_stripped() {
34616        let result = display_path(Path::new(r"\\?\UNC\server\share"));
34617        assert_eq!(result, r"\\server\share");
34618    }
34619}
34620
34621#[cfg(test)]
34622mod coverage_boost_unit_tests {
34623    use super::*;
34624    use std::path::{Path, PathBuf};
34625
34626    // Both scenarios live in one test (sequential, under a Tokio runtime) because
34627    // load_runtime_security_config spawns a pruning task and mutates process-global
34628    // env vars — parallel sub-tests would race on both.
34629    #[tokio::test]
34630    async fn runtime_security_config_scenarios() {
34631        std::env::remove_var("SLOC_API_KEYS");
34632        std::env::remove_var("SLOC_API_KEY");
34633        std::env::remove_var("SLOC_TLS_CERT");
34634        std::env::remove_var("SLOC_TLS_KEY");
34635        std::env::remove_var("SLOC_TRUST_PROXY");
34636        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34637        let cfg = load_runtime_security_config(false);
34638        assert!(cfg.api_keys.is_empty());
34639        assert!(!cfg.tls_enabled);
34640        assert!(!cfg.trust_proxy);
34641
34642        std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
34643        std::env::set_var("SLOC_TRUST_PROXY", "1");
34644        std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
34645        std::env::set_var("SLOC_RATE_LIMIT", "250");
34646        std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
34647        std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
34648        let cfg = load_runtime_security_config(true);
34649        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34650        assert!(cfg.trust_proxy);
34651        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34652        std::env::remove_var("SLOC_API_KEYS");
34653        std::env::remove_var("SLOC_TRUST_PROXY");
34654        std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34655        std::env::remove_var("SLOC_RATE_LIMIT");
34656        std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
34657        std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
34658    }
34659
34660    #[test]
34661    fn cors_layer_builds_both_modes() {
34662        let _ = build_cors_layer(true);
34663        let _ = build_cors_layer(false);
34664    }
34665
34666    #[test]
34667    fn primary_lan_ip_callable() {
34668        // May be Some or None depending on the host; both are valid.
34669        let _ = primary_lan_ip();
34670    }
34671
34672    #[test]
34673    fn safe_redirect_allows_relative_rejects_absolute() {
34674        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34675        assert_eq!(safe_redirect("https://evil.example/x"), "/");
34676        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34677        assert_eq!(default_redirect(), "/view-reports");
34678    }
34679
34680    #[test]
34681    fn tarball_size_caps_env_override() {
34682        std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
34683        std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
34684        let (c, d) = parse_tarball_size_caps();
34685        assert_eq!(c, 1024 * 1024);
34686        assert_eq!(d, 2 * 1024 * 1024);
34687        std::env::remove_var("SLOC_MAX_TARBALL_MB");
34688        std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
34689        let (c2, _) = parse_tarball_size_caps();
34690        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34691    }
34692
34693    #[test]
34694    fn upload_path_helpers() {
34695        let base = upload_base_dir();
34696        let staged = upload_staging_path("abc123");
34697        assert!(staged.starts_with(&base));
34698        assert!(
34699            is_upload_tmp_path(&staged),
34700            "staging path is an upload tmp path"
34701        );
34702        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34703    }
34704
34705    #[test]
34706    fn git_clones_dir_env_override() {
34707        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34708        let def = resolve_git_clones_dir(Path::new("/out"));
34709        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34710        std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34711        assert_eq!(
34712            resolve_git_clones_dir(Path::new("/out")),
34713            PathBuf::from("/custom/clones")
34714        );
34715        std::env::remove_var("SLOC_GIT_CLONES_DIR");
34716    }
34717
34718    #[test]
34719    fn html_report_file_detection() {
34720        let dir = std::env::temp_dir().join("sloc_html_detect");
34721        let _ = std::fs::create_dir_all(&dir);
34722        let good = dir.join("report_x.html");
34723        std::fs::write(&good, "<html></html>").unwrap();
34724        let bad = dir.join("notes.txt");
34725        std::fs::write(&bad, "x").unwrap();
34726        assert!(is_html_report_file(&good));
34727        assert!(!is_html_report_file(&bad));
34728        assert!(find_html_report_in_dir(&dir).is_some());
34729        let _ = std::fs::remove_dir_all(&dir);
34730    }
34731
34732    #[test]
34733    fn multi_delta_class_and_format() {
34734        assert_eq!(multi_delta_class(5), "pos");
34735        assert_eq!(multi_delta_class(-5), "neg");
34736        assert_eq!(multi_delta_class(0), "zero");
34737        assert_eq!(multi_fmt_delta(3), "+3");
34738        assert_eq!(multi_fmt_delta(-3), "-3");
34739        assert_eq!(multi_fmt_delta(0), "0");
34740    }
34741
34742    #[test]
34743    fn git_clone_dest_sanitizes() {
34744        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34745        assert!(dest.starts_with("/clones"));
34746        let name = dest.file_name().unwrap().to_str().unwrap();
34747        assert!(name
34748            .chars()
34749            .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34750    }
34751}
34752
34753#[cfg(test)]
34754mod tests_private {
34755    use super::*;
34756    use std::io::Read;
34757
34758    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
34759
34760    #[test]
34761    fn local_mode_never_refuses_start() {
34762        // Desktop / local mode is open by design regardless of key presence.
34763        assert!(!refuse_unauthenticated_server(false, false));
34764        assert!(!refuse_unauthenticated_server(false, true));
34765    }
34766
34767    #[test]
34768    fn server_mode_with_key_is_allowed() {
34769        assert!(!refuse_unauthenticated_server(true, true));
34770    }
34771
34772    // Env-mutating assertions live in one test so they run sequentially: the
34773    // process-global env var would otherwise race across parallel test threads.
34774    #[test]
34775    fn server_mode_auth_gate_respects_optin() {
34776        std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34777        assert!(
34778            refuse_unauthenticated_server(true, false),
34779            "server mode + no key must fail closed by default"
34780        );
34781        std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1");
34782        assert!(
34783            !refuse_unauthenticated_server(true, false),
34784            "explicit opt-in must allow the unauthenticated server"
34785        );
34786        std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34787    }
34788
34789    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
34790
34791    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
34792    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
34793    /// genuinely malicious archive, which is where the zip-slip guard must hold.
34794    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34795        let mut h = [0u8; 512];
34796        let nb = name.as_bytes();
34797        h[..nb.len()].copy_from_slice(nb);
34798        h[100..108].copy_from_slice(b"0000644\0");
34799        h[108..116].copy_from_slice(b"0000000\0");
34800        h[116..124].copy_from_slice(b"0000000\0");
34801        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34802        h[136..148].copy_from_slice(b"00000000000\0");
34803        h[156] = b'0'; // typeflag: regular file
34804        h[257..263].copy_from_slice(b"ustar\0");
34805        h[263..265].copy_from_slice(b"00");
34806        for b in &mut h[148..156] {
34807            *b = b' ';
34808        }
34809        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34810        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34811
34812        let mut out = h.to_vec();
34813        out.extend_from_slice(data);
34814        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
34815        out.resize(out.len() + 1024, 0); // two trailing zero blocks
34816        out
34817    }
34818
34819    /// A malicious tar whose entry path escapes the destination via `..` must not
34820    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
34821    /// zip-slip guard as a regression test.
34822    #[tokio::test]
34823    async fn tarball_extraction_blocks_zip_slip() {
34824        use std::io::Write as _;
34825
34826        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34827        let staging = base.join("staging");
34828        let tar_gz = base.join("evil.tar.gz");
34829        std::fs::create_dir_all(&base).unwrap();
34830
34831        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
34832        {
34833            let f = std::fs::File::create(&tar_gz).unwrap();
34834            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34835            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34836                .unwrap();
34837            enc.finish().unwrap().flush().unwrap();
34838        }
34839
34840        // Extraction must not write the escaped file beside the staging directory.
34841        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34842
34843        let escaped = base.join("escaped.txt");
34844        assert!(
34845            !escaped.exists(),
34846            "zip-slip entry escaped staging to {}",
34847            escaped.display()
34848        );
34849
34850        let _ = std::fs::remove_dir_all(&base);
34851    }
34852
34853    #[test]
34854    fn size_limit_reader_zero_remaining_returns_error() {
34855        let data = b"hello world";
34856        let mut reader = SizeLimitReader {
34857            inner: &data[..],
34858            remaining: 0,
34859        };
34860        let mut buf = [0u8; 4];
34861        assert!(reader.read(&mut buf).is_err());
34862    }
34863
34864    #[test]
34865    fn size_limit_reader_counts_bytes() {
34866        let data = b"hello world";
34867        let mut reader = SizeLimitReader {
34868            inner: &data[..],
34869            remaining: 5,
34870        };
34871        let mut buf = [0u8; 4];
34872        let n = reader.read(&mut buf).unwrap();
34873        assert_eq!(n, 4);
34874        assert_eq!(reader.remaining, 1);
34875    }
34876
34877    #[test]
34878    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34879        let uuid = "12345678-1234-1234-1234-123456789012";
34880        let (id, path) = resolve_or_create_staging(Some(uuid));
34881        assert_eq!(id, uuid);
34882        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34883    }
34884
34885    #[test]
34886    fn resolve_or_create_staging_with_none_creates_new() {
34887        let (id1, _) = resolve_or_create_staging(None);
34888        let (id2, _) = resolve_or_create_staging(None);
34889        assert_ne!(id1, id2);
34890    }
34891
34892    #[test]
34893    fn resolve_or_create_staging_with_path_separator_creates_new() {
34894        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
34895        let (id, _) = resolve_or_create_staging(Some("has/slash"));
34896        assert_ne!(id, "has/slash");
34897    }
34898
34899    #[test]
34900    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34901        use std::net::IpAddr;
34902        use std::str::FromStr;
34903        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34904        let ip = IpAddr::from_str("192.168.1.1").unwrap();
34905        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34906    }
34907
34908    #[test]
34909    fn is_auth_locked_out_expired_entry_removed() {
34910        use std::net::IpAddr;
34911        use std::str::FromStr;
34912        let limiter = IpRateLimiter::new(
34913            Duration::from_mins(1),
34914            100,
34915            1, // 1 failure triggers lockout
34916            Duration::from_millis(1),
34917        );
34918        let ip = IpAddr::from_str("192.168.1.2").unwrap();
34919        limiter.record_auth_failure(ip);
34920        // Wait for the 1ms window to expire
34921        std::thread::sleep(Duration::from_millis(10));
34922        // Expired entry should be removed, returning false
34923        assert!(!limiter.is_auth_locked_out(ip));
34924    }
34925
34926    #[test]
34927    fn is_auth_locked_out_within_window_returns_true() {
34928        use std::net::IpAddr;
34929        use std::str::FromStr;
34930        let limiter = IpRateLimiter::new(
34931            Duration::from_mins(1),
34932            100,
34933            2, // 2 failures triggers lockout
34934            Duration::from_hours(1),
34935        );
34936        let ip = IpAddr::from_str("192.168.1.3").unwrap();
34937        limiter.record_auth_failure(ip);
34938        limiter.record_auth_failure(ip);
34939        assert!(limiter.is_auth_locked_out(ip));
34940    }
34941
34942    // ── output_folder_hint ───────────────────────────────────────────────────────
34943
34944    #[test]
34945    fn output_folder_hint_strips_json_subdir() {
34946        use std::path::Path;
34947        let path = Path::new("/output/scan1/json/result.json");
34948        let hint = output_folder_hint(path);
34949        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34950    }
34951
34952    #[test]
34953    fn output_folder_hint_strips_html_subdir() {
34954        use std::path::Path;
34955        let path = Path::new("/output/scan1/html/report.html");
34956        let hint = output_folder_hint(path);
34957        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34958    }
34959
34960    #[test]
34961    fn output_folder_hint_strips_pdf_subdir() {
34962        use std::path::Path;
34963        let path = Path::new("/output/scan1/pdf/report.pdf");
34964        let hint = output_folder_hint(path);
34965        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34966    }
34967
34968    #[test]
34969    fn output_folder_hint_strips_excel_subdir() {
34970        use std::path::Path;
34971        let path = Path::new("/output/scan1/excel/report.xlsx");
34972        let hint = output_folder_hint(path);
34973        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34974    }
34975
34976    #[test]
34977    fn output_folder_hint_flat_layout_returns_direct_parent() {
34978        use std::path::Path;
34979        let path = Path::new("/output/scan1/result.json");
34980        let hint = output_folder_hint(path);
34981        assert!(
34982            hint.ends_with("scan1"),
34983            "expected direct parent, got: {hint}"
34984        );
34985    }
34986
34987    #[test]
34988    fn output_folder_hint_other_subdir_name_not_stripped() {
34989        use std::path::Path;
34990        // "data" is not one of the named artifact subdirs — parent is kept as-is
34991        let path = Path::new("/output/scan1/data/result.json");
34992        let hint = output_folder_hint(path);
34993        assert!(
34994            hint.ends_with("data"),
34995            "non-artifact subdir must not be stripped, got: {hint}"
34996        );
34997    }
34998
34999    // ── find_file_by_ext ─────────────────────────────────────────────────────────
35000
35001    #[test]
35002    fn find_file_by_ext_finds_matching_file() {
35003        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35004        let _ = fs::create_dir_all(&dir);
35005        let f = dir.join("report.pdf");
35006        let _ = fs::write(&f, b"dummy");
35007        let result = find_file_by_ext(&dir, "pdf");
35008        assert!(result.is_some(), "expected to find report.pdf");
35009        let _ = fs::remove_dir_all(&dir);
35010    }
35011
35012    #[test]
35013    fn find_file_by_ext_returns_none_for_missing_ext() {
35014        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35015        let _ = fs::create_dir_all(&dir);
35016        let f = dir.join("report.json");
35017        let _ = fs::write(&f, b"{}");
35018        let result = find_file_by_ext(&dir, "pdf");
35019        assert!(result.is_none());
35020        let _ = fs::remove_dir_all(&dir);
35021    }
35022
35023    #[test]
35024    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35025        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35026        assert!(find_file_by_ext(dir, "json").is_none());
35027    }
35028
35029    // ── collect_result_json_candidates ───────────────────────────────────────────
35030
35031    #[test]
35032    fn collect_result_json_candidates_flat_root() {
35033        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35034        let _ = fs::create_dir_all(&root);
35035        let _ = fs::write(root.join("result.json"), b"{}");
35036        let candidates = collect_result_json_candidates(&root);
35037        assert!(!candidates.is_empty(), "should find result.json at root");
35038        let _ = fs::remove_dir_all(&root);
35039    }
35040
35041    #[test]
35042    fn collect_result_json_candidates_legacy_subdir() {
35043        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35044        let sub = root.join("scanA");
35045        let _ = fs::create_dir_all(&sub);
35046        let _ = fs::write(sub.join("result.json"), b"{}");
35047        let candidates = collect_result_json_candidates(&root);
35048        assert!(
35049            !candidates.is_empty(),
35050            "should find result.json in legacy subdir"
35051        );
35052        let _ = fs::remove_dir_all(&root);
35053    }
35054
35055    #[test]
35056    fn collect_result_json_candidates_structured_json_subdir() {
35057        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35058        let json_sub = root.join("scanB").join("json");
35059        let _ = fs::create_dir_all(&json_sub);
35060        let _ = fs::write(json_sub.join("result.json"), b"{}");
35061        let candidates = collect_result_json_candidates(&root);
35062        assert!(
35063            !candidates.is_empty(),
35064            "should find result.json inside <subdir>/json/"
35065        );
35066        let _ = fs::remove_dir_all(&root);
35067    }
35068
35069    #[test]
35070    fn collect_result_json_candidates_empty_dir() {
35071        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35072        let _ = fs::create_dir_all(&root);
35073        let candidates = collect_result_json_candidates(&root);
35074        assert!(candidates.is_empty());
35075        let _ = fs::remove_dir_all(&root);
35076    }
35077}