Skip to main content

sloc_web/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{AuditVerifyReport, verify_audit_file};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod connectivity;
30pub(crate) mod error;
31pub(crate) mod git_browser;
32pub(crate) mod git_webhook;
33pub(crate) mod integrations;
34pub(crate) mod report_bug;
35
36use std::{
37    collections::{HashMap, VecDeque},
38    fmt::Write,
39    fs,
40    net::{IpAddr, SocketAddr, ToSocketAddrs},
41    path::{Path, PathBuf},
42    process::Stdio,
43    sync::{Arc, OnceLock},
44    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
45};
46
47use anyhow::{Context, Result};
48use askama::Template;
49use axum::{
50    Json, Router,
51    body::Body,
52    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
53    http::{HeaderValue, Request, StatusCode, header},
54    middleware::{self, Next},
55    response::{Html, IntoResponse, Response},
56    routing::{get, post},
57};
58use serde::{Deserialize, Serialize};
59use tokio::sync::Mutex;
60use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
61
62use sloc_config::{
63    AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
64    MixedLinePolicy,
65};
66use sloc_git::ScheduleStore;
67
68#[derive(Clone)]
69pub(crate) struct CspNonce(pub(crate) String);
70
71static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
72static APP_CSS: &str = include_str!("../static/app.css");
73static APP_JS: &str = include_str!("../static/app.js");
74static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
75
76use sloc_core::{
77    AnalysisRun, Author, AuthorMergeGroup, CleanupPolicy, CleanupPolicyStore, FileChangeStatus,
78    IdentityMap, MultiScanComparison, RegistryEntry, ScanRegistry, ScanSummarySnapshot,
79    SummaryTotals, WatchedDirsStore, analyze, apply_identity_map, auto_merge_noreply_identities,
80    compute_delta, compute_multi_delta, read_json,
81};
82use sloc_report::{
83    ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
84    write_pdf_from_html, write_pdf_from_run,
85};
86const MAX_CONCURRENT_ANALYSES: usize = 4;
87
88/// Windows-only helpers that force the native file-picker dialog into the
89/// foreground instead of appearing minimised behind other windows.
90///
91/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
92/// foreground thread so that windows created on our thread inherit focus; and
93/// (b) spin a polling watcher that finds the dialog by title and calls
94/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
95#[cfg(target_os = "windows")]
96#[allow(clippy::upper_case_acronyms)]
97#[allow(dead_code)]
98mod win_dialog_focus {
99    #[cfg(feature = "native-dialog")]
100    use std::mem::size_of;
101
102    type HWND = *mut core::ffi::c_void;
103    type DWORD = u32;
104    type UINT = u32;
105    type BOOL = i32;
106
107    // Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
108    #[cfg(feature = "native-dialog")]
109    #[repr(C)]
110    #[allow(non_snake_case)]
111    struct FLASHWINFO {
112        cbSize: UINT,
113        hwnd: HWND,
114        dwFlags: DWORD,
115        uCount: UINT,
116        dwTimeout: DWORD,
117    }
118
119    #[cfg(feature = "native-dialog")]
120    const FLASHW_ALL: DWORD = 0x3;
121    #[cfg(feature = "native-dialog")]
122    const FLASHW_TIMERNOFG: DWORD = 0xC;
123
124    #[link(name = "user32")]
125    unsafe extern "system" {
126        fn GetForegroundWindow() -> HWND;
127        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
128        fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
129        fn BringWindowToTop(hWnd: HWND) -> BOOL;
130        fn SetWindowPos(
131            hWnd: HWND,
132            hWndAfter: HWND,
133            x: i32,
134            y: i32,
135            cx: i32,
136            cy: i32,
137            flags: UINT,
138        ) -> BOOL;
139        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
140        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
141        // Synthesises a keystroke. We tap ALT to mark our process as the last to
142        // receive input, which lifts Windows' foreground lock (see bring_to_front).
143        fn keybd_event(bVk: u8, bScan: u8, dwFlags: DWORD, dwExtraInfo: usize);
144        #[cfg(feature = "native-dialog")]
145        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
146        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
147        fn FindWindowExW(
148            hWndParent: HWND,
149            hWndChildAfter: HWND,
150            lpszClass: *const u16,
151            lpszWindow: *const u16,
152        ) -> HWND;
153        // Undocumented but present on all Windows versions since XP; bypasses
154        // the foreground-lock that blocks SetForegroundWindow from non-foreground
155        // processes.  fAltTab=1 simulates the Alt+Tab activation path.
156        fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
157    }
158
159    #[link(name = "kernel32")]
160    unsafe extern "system" {
161        fn GetCurrentThreadId() -> DWORD;
162    }
163
164    #[link(name = "shell32")]
165    unsafe extern "system" {
166        // Opens a folder (or file) via the Windows shell.  Passing the current
167        // foreground window as `hwnd` gives the new window proper activation
168        // context so it surfaces in the foreground without needing
169        // AttachThreadInput or SetForegroundWindow hacks.
170        fn ShellExecuteW(
171            hwnd: HWND,
172            lpOperation: *const u16,
173            lpFile: *const u16,
174            lpParameters: *const u16,
175            lpDirectory: *const u16,
176            nShowCmd: i32,
177        ) -> isize; // HINSTANCE (>32 = success)
178    }
179
180    /// Attaches our thread's input to the foreground window's thread so that
181    /// windows created on our thread inherit foreground focus.  Returns the
182    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
183    /// the thread was already the foreground thread.
184    #[cfg(feature = "native-dialog")]
185    pub fn attach_to_foreground() -> DWORD {
186        unsafe {
187            let fg_hwnd = GetForegroundWindow();
188            if fg_hwnd.is_null() {
189                return 0;
190            }
191            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
192            let my_tid = GetCurrentThreadId();
193            if fg_tid == my_tid {
194                return 0;
195            }
196            AttachThreadInput(my_tid, fg_tid, 1);
197            fg_tid
198        }
199    }
200
201    /// Undoes `attach_to_foreground`.
202    #[cfg(feature = "native-dialog")]
203    pub fn detach_from_foreground(fg_tid: DWORD) {
204        if fg_tid == 0 {
205            return;
206        }
207        unsafe {
208            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
209        }
210    }
211
212    unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
213        unsafe {
214            let mut existing = std::collections::HashSet::new();
215            let mut prev: HWND = core::ptr::null_mut();
216            loop {
217                let w = FindWindowExW(
218                    core::ptr::null_mut(),
219                    prev,
220                    class_w.as_ptr(),
221                    core::ptr::null(),
222                );
223                if w.is_null() {
224                    break;
225                }
226                existing.insert(w as usize);
227                prev = w;
228            }
229            existing
230        }
231    }
232
233    unsafe fn find_new_explorer_hwnd(
234        class_w: &[u16],
235        existing: &std::collections::HashSet<usize>,
236    ) -> Option<HWND> {
237        unsafe {
238            let mut prev: HWND = core::ptr::null_mut();
239            loop {
240                let w = FindWindowExW(
241                    core::ptr::null_mut(),
242                    prev,
243                    class_w.as_ptr(),
244                    core::ptr::null(),
245                );
246                if w.is_null() {
247                    return None;
248                }
249                if !existing.contains(&(w as usize)) {
250                    return Some(w);
251                }
252                prev = w;
253            }
254        }
255    }
256
257    unsafe fn bring_to_front(hwnd: HWND) {
258        unsafe {
259            // Surfacing a window owned by another process (Explorer) from a
260            // background thread is blocked by Windows' foreground lock:
261            // SetForegroundWindow silently fails and only the taskbar button
262            // flashes.  The reliable workaround is to temporarily attach our input
263            // queue to the thread that currently owns the foreground window — while
264            // attached, SetForegroundWindow/BringWindowToTop actually activate the
265            // window instead of merely flashing it.
266            let my_tid = GetCurrentThreadId();
267            let fg_hwnd = GetForegroundWindow();
268            let fg_tid = if fg_hwnd.is_null() {
269                0
270            } else {
271                GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
272            };
273            let attached =
274                fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
275
276            // Windows 10/11 hardened the foreground lock so that AttachThreadInput
277            // alone no longer reliably activates a window owned by another process
278            // (Explorer) — SetForegroundWindow silently fails and only the taskbar
279            // button flashes.  Synthesising a tap of the ALT key marks *our* process
280            // as the one that received the last input event, which is one of the
281            // documented conditions under which the lock is lifted, so the
282            // SetForegroundWindow below actually activates the window.
283            // VK_MENU = 0x12; KEYEVENTF_KEYUP = 0x0002.
284            keybd_event(0x12, 0, 0, 0);
285            keybd_event(0x12, 0, 0x0002, 0);
286
287            // SW_RESTORE = 9 — un-minimise the Explorer window (it may have opened
288            // as a taskbar button) without forcing a full-screen maximise.
289            ShowWindow(hwnd, 9);
290            BringWindowToTop(hwnd);
291            SetForegroundWindow(hwnd);
292            // Extra belt-and-braces activation that also bypasses the foreground
293            // lock on older Windows builds.
294            SwitchToThisWindow(hwnd, 1);
295
296            // Force the Z-order to the very top regardless of the foreground-lock
297            // outcome by flipping TOPMOST on then off, so the window jumps above all
298            // others without staying pinned. HWND_TOPMOST = -1, HWND_NOTOPMOST = -2;
299            // SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE = 0x0013.
300            SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
301            SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
302
303            if attached {
304                AttachThreadInput(my_tid, fg_tid, 0);
305            }
306        }
307    }
308
309    /// Opens `path` in Windows Explorer and forces it to the foreground.
310    /// `ShellExecuteW` alone cannot guarantee foreground placement when the
311    /// caller is not the foreground process (the browser is).  After launching,
312    /// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
313    /// an undocumented API that bypasses Windows' foreground-lock restriction
314    /// so the window surfaces regardless of which process currently has focus.
315    pub fn open_folder_foreground(path: std::path::PathBuf) {
316        std::thread::spawn(move || {
317            use std::os::windows::ffi::OsStrExt;
318
319            let op: Vec<u16> = "explore\0".encode_utf16().collect();
320            let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
321            path_w.push(0);
322            let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
323
324            unsafe {
325                // Snapshot every existing Explorer window before we launch so
326                // we can identify the newly created one.
327                let existing = snapshot_explorer_hwnds(&class_w);
328                let fg_hwnd = GetForegroundWindow();
329                // SW_SHOWNORMAL = 1
330                ShellExecuteW(
331                    fg_hwnd,
332                    op.as_ptr(),
333                    path_w.as_ptr(),
334                    core::ptr::null(),
335                    core::ptr::null(),
336                    1,
337                );
338
339                // Poll up to ~3 s for a new CabinetWClass window to appear,
340                // then use SwitchToThisWindow (bypasses foreground-lock) to
341                // bring it in front of the browser and everything else.
342                for _ in 0..40 {
343                    std::thread::sleep(std::time::Duration::from_millis(75));
344                    if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
345                        bring_to_front(w);
346                        return;
347                    }
348                }
349
350                // Fallback: Explorer reused an existing window — bring whichever
351                // CabinetWClass window is first in Z-order to the front.
352                let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
353                if !w.is_null() {
354                    bring_to_front(w);
355                }
356            }
357        });
358    }
359
360    /// Spawns a short-lived watcher thread that polls for a dialog window
361    /// matching `title` and, once found, forces it to the foreground and
362    /// flashes its taskbar button until the user interacts with it.
363    #[cfg(feature = "native-dialog")]
364    pub fn flash_dialog_when_ready(title: String) {
365        std::thread::spawn(move || {
366            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
367            for _ in 0..40 {
368                std::thread::sleep(std::time::Duration::from_millis(80));
369                unsafe {
370                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
371                    if !hwnd.is_null() {
372                        SetForegroundWindow(hwnd);
373                        BringWindowToTop(hwnd);
374                        #[allow(non_snake_case)]
375                        FlashWindowEx(&FLASHWINFO {
376                            // size_of returns usize; Win32 struct field is u32 (UINT).
377                            // struct size fits trivially within u32.
378                            #[allow(clippy::cast_possible_truncation)]
379                            cbSize: size_of::<FLASHWINFO>() as UINT,
380                            hwnd,
381                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
382                            uCount: 3,
383                            dwTimeout: 0,
384                        });
385                        break;
386                    }
387                }
388            }
389        });
390    }
391}
392
393/// Sliding-window rate limiter keyed by client IP.
394/// Uses only std primitives — no external crate required.
395pub(crate) struct IpRateLimiter {
396    window: Duration,
397    max_requests: usize,
398    pub(crate) auth_lockout_threshold: u32,
399    auth_lockout_window: Duration,
400    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
401    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
402}
403
404impl IpRateLimiter {
405    pub(crate) fn new(
406        window: Duration,
407        max_requests: usize,
408        auth_lockout_threshold: u32,
409        auth_lockout_window: Duration,
410    ) -> Self {
411        Self {
412            window,
413            max_requests,
414            auth_lockout_threshold,
415            auth_lockout_window,
416            state: std::sync::Mutex::new(HashMap::new()),
417            auth_failures: std::sync::Mutex::new(HashMap::new()),
418        }
419    }
420
421    // The MutexGuard `state` must live as long as `bucket` borrows from it,
422    // so it cannot be dropped any earlier than the end of the inner block.
423    #[allow(clippy::significant_drop_tightening)]
424    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
425        let now = Instant::now();
426        let cutoff = now.checked_sub(self.window).unwrap_or(now);
427        let mut state = self
428            .state
429            .lock()
430            .unwrap_or_else(std::sync::PoisonError::into_inner);
431        if state.len() > 10_000 {
432            state.retain(|_, bucket| {
433                while bucket.front().is_some_and(|t| *t <= cutoff) {
434                    bucket.pop_front();
435                }
436                !bucket.is_empty()
437            });
438        }
439        let bucket = state.entry(ip).or_default();
440        while bucket.front().is_some_and(|t| *t <= cutoff) {
441            bucket.pop_front();
442        }
443        if bucket.len() >= self.max_requests {
444            false
445        } else {
446            bucket.push_back(now);
447            true
448        }
449    }
450
451    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
452        let now = Instant::now();
453        let mut map = self
454            .auth_failures
455            .lock()
456            .unwrap_or_else(std::sync::PoisonError::into_inner);
457        map.entry(ip)
458            .and_modify(|e| {
459                e.0 += 1;
460                e.1 = now;
461            })
462            .or_insert_with(|| (1, now));
463    }
464
465    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
466        let mut map = self
467            .auth_failures
468            .lock()
469            .unwrap_or_else(std::sync::PoisonError::into_inner);
470        let expired = map
471            .get(&ip)
472            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
473        if expired {
474            map.remove(&ip);
475            return false;
476        }
477        map.get(&ip)
478            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
479    }
480
481    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
482        let map = self
483            .auth_failures
484            .lock()
485            .unwrap_or_else(std::sync::PoisonError::into_inner);
486        map.get(&ip).map_or(0, |e| {
487            self.auth_lockout_window
488                .checked_sub(e.1.elapsed())
489                .map_or(0, |r| r.as_secs())
490        })
491    }
492
493    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
494        tokio::spawn(async move {
495            let mut interval = tokio::time::interval(Duration::from_mins(1));
496            interval.tick().await; // consume the immediate first tick
497            loop {
498                interval.tick().await;
499                let now = Instant::now();
500                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
501                {
502                    let mut state = limiter
503                        .state
504                        .lock()
505                        .unwrap_or_else(std::sync::PoisonError::into_inner);
506                    state.retain(|_, bucket| {
507                        while bucket.front().is_some_and(|t| *t <= cutoff) {
508                            bucket.pop_front();
509                        }
510                        !bucket.is_empty()
511                    });
512                }
513                {
514                    let mut auth = limiter
515                        .auth_failures
516                        .lock()
517                        .unwrap_or_else(std::sync::PoisonError::into_inner);
518                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
519                }
520            }
521        });
522    }
523}
524
525/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
526/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
527/// files but never triggers a scan.
528fn spawn_upload_staging_cleanup() {
529    tokio::spawn(async move {
530        let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
531            .ok()
532            .and_then(|v| v.parse().ok())
533            .unwrap_or(4);
534        let ttl_secs = ttl_hours * 3600;
535        let mut interval = tokio::time::interval(Duration::from_hours(1));
536        interval.tick().await; // consume the immediate first tick
537        loop {
538            interval.tick().await;
539            let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
540            let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
541                continue;
542            };
543            while let Ok(Some(entry)) = dir.next_entry().await {
544                let path = entry.path();
545                let age_secs = tokio::fs::metadata(&path)
546                    .await
547                    .ok()
548                    .and_then(|m| m.modified().ok())
549                    .and_then(|t| t.elapsed().ok())
550                    .map_or(0, |d| d.as_secs());
551                if age_secs > ttl_secs {
552                    tracing::debug!(
553                        event = "upload_staging_cleanup",
554                        path = %path.display(),
555                        age_secs,
556                        "removing stale upload staging directory"
557                    );
558                    let _ = tokio::fs::remove_dir_all(&path).await;
559                }
560            }
561        }
562    });
563}
564
565/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
566#[derive(Clone, Debug, Default)]
567struct RunResultContext {
568    prev_entry: Option<RegistryEntry>,
569    prev_scan_count: usize,
570    project_path: String,
571    /// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
572    cocomo_mode: String,
573    /// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
574    complexity_alert: u32,
575    /// Whether duplicate files should be excluded from displayed SLOC totals.
576    #[allow(dead_code)]
577    exclude_duplicates: bool,
578}
579
580/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
581#[derive(Clone)]
582enum AsyncRunState {
583    Running {
584        started_at: std::time::Instant,
585        cancel_token: Arc<std::sync::atomic::AtomicBool>,
586        phase: Arc<std::sync::Mutex<String>>,
587        files_done: Arc<std::sync::atomic::AtomicUsize>,
588        files_total: Arc<std::sync::atomic::AtomicUsize>,
589        attrib_done: Arc<std::sync::atomic::AtomicUsize>,
590        attrib_total: Arc<std::sync::atomic::AtomicUsize>,
591    },
592    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
593    Complete {
594        run_id: String,
595    },
596    Failed {
597        message: String,
598    },
599    Cancelled,
600}
601
602/// A saved scan configuration profile — stores the form parameters so users can
603/// re-run a favourite scan with one click.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605struct ScanProfile {
606    id: String,
607    name: String,
608    created_at: String,
609    /// The raw scan-form parameters serialized as JSON.
610    params: serde_json::Value,
611}
612
613#[derive(Debug, Clone, Default, Serialize, Deserialize)]
614struct ScanProfileStore {
615    profiles: Vec<ScanProfile>,
616}
617
618impl ScanProfileStore {
619    fn load(path: &std::path::Path) -> Self {
620        fs::read_to_string(path)
621            .ok()
622            .and_then(|s| serde_json::from_str(&s).ok())
623            .unwrap_or_default()
624    }
625
626    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
627        let path = sloc_core::reject_traversal(path)?;
628        if let Some(parent) = path.parent() {
629            fs::create_dir_all(parent)?;
630        }
631        let json = serde_json::to_string_pretty(self)?;
632        fs::write(&path, json)?;
633        Ok(())
634    }
635}
636
637/// Server-side session record. `absolute_expiry` is the hard 8-hour cap (unchanged);
638/// `last_seen` supports the optional sliding idle timeout (see `session_idle_timeout`).
639#[derive(Clone, Copy)]
640pub(crate) struct SessionState {
641    pub(crate) absolute_expiry: Instant,
642    pub(crate) last_seen: Instant,
643}
644
645// The bool fields below are independent runtime flags (server mode, unauth-allow,
646// TLS, proxy trust), not a state machine. Folding them into an enum/sub-struct would
647// churn every construction and access site across this crate for no clarity gain —
648// and that mechanical churn is exactly what risks the new_duplicated_lines_density
649// gate. Scope the allow to this struct rather than refactoring.
650#[allow(clippy::struct_excessive_bools)]
651#[derive(Clone)]
652pub(crate) struct AppState {
653    pub(crate) base_config: AppConfig,
654    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
655    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
656    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
657    pub(crate) registry_path: PathBuf,
658    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
659    pub(crate) server_mode: bool,
660    /// Operator explicitly accepted running server mode with no API key
661    /// (`SLOC_ALLOW_UNAUTHENTICATED=1`). When false, an unauthenticated server-mode
662    /// request fails closed with 503 instead of being served open.
663    pub(crate) allow_unauthenticated: bool,
664    pub(crate) tls_enabled: bool,
665    pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
666    /// Read-only credentials (`SLOC_API_KEYS_READONLY`): authenticate for safe
667    /// (GET/HEAD/OPTIONS) requests but are rejected on state-changing methods.
668    /// Empty by default, so all keys are full-access — the prior behaviour.
669    pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
670    pub(crate) rate_limiter: Arc<IpRateLimiter>,
671    pub(crate) trust_proxy: bool,
672    /// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
673    /// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
674    pub(crate) trusted_proxy_ips: Vec<IpAddr>,
675    /// Directory where remote repositories are cloned for git-browser scans.
676    pub(crate) git_clones_dir: PathBuf,
677    /// Persisted list of webhook / poll schedules.
678    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
679    pub(crate) schedules_path: PathBuf,
680    /// Named scan profiles saved by the user via the web UI.
681    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
682    pub(crate) scan_profiles_path: PathBuf,
683    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
684    /// Persisted Confluence integration settings.
685    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
686    pub(crate) confluence_path: PathBuf,
687    /// Directories the user has pinned for auto-scanning of external reports.
688    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
689    pub(crate) watched_dirs_path: PathBuf,
690    /// Persisted auto-cleanup policy (age/count limits + interval).
691    pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
692    pub(crate) cleanup_policy_path: PathBuf,
693    /// Handle for the running cleanup background task; replaced on policy change.
694    pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
695}
696
697type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
698
699/// Parameters for the fire-and-forget HTML + PDF background task.
700
701#[derive(Clone, Debug)]
702pub(crate) struct RunArtifacts {
703    output_dir: PathBuf,
704    html_path: Option<PathBuf>,
705    pdf_path: Option<PathBuf>,
706    json_path: Option<PathBuf>,
707    csv_path: Option<PathBuf>,
708    xlsx_path: Option<PathBuf>,
709    scan_config_path: Option<PathBuf>,
710    report_title: String,
711    result_context: RunResultContext,
712}
713
714/// Canonical categorical chart palette (see the Design section of CLAUDE.md). Used to colour
715/// per-author bars on the Code Ownership page so they match every other visualization.
716const OWNERSHIP_PALETTE: &[&str] = &[
717    "#C45C10", "#2A6846", "#4472C4", "#805099", "#D4A017", "#B23030", "#2E75B6", "#70AD47",
718    "#FF9900", "#9E480E", "#636363", "#156082", "#D0743C", "#5BA8A0",
719];
720
721/// One author's computed display row for the Code Ownership page.
722struct OwnershipRow {
723    name: String,
724    email: String,
725    code: u64,
726    comment: u64,
727    blank: u64,
728    total: u64,
729    code_pct: f64,
730    files_owned: u64,
731    aliases: usize,
732    color: &'static str,
733    /// Best-effort link to this contributor's profile / contributions on the hosting platform,
734    /// derived from the repo's git remote. `None` when it can't be resolved.
735    profile: Option<String>,
736    /// Lines owned in files classified as tests (subset of the totals above). The "development"
737    /// slice is derived as `total - test` on the client so the dev/test filter stays consistent.
738    test_code: u64,
739    test_comment: u64,
740    test_blank: u64,
741    test_total: u64,
742}
743
744/// Minimal percent-encoder for a URL query-string value (RFC 3986 unreserved set kept literal).
745fn url_query_encode(s: &str) -> String {
746    let mut out = String::with_capacity(s.len());
747    for b in s.bytes() {
748        match b {
749            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
750                out.push(b as char);
751            }
752            _ => out.push_str(&format!("%{b:02X}")),
753        }
754    }
755    out
756}
757
758/// Split a git remote URL into its raw `(host, path)` components, handling `https://`, `ssh://`,
759/// and scp-style `git@host:path` forms (credentials in the authority are dropped).
760fn split_git_remote(r: &str) -> Option<(String, String)> {
761    if let Some(rest) = r
762        .strip_prefix("https://")
763        .or_else(|| r.strip_prefix("http://"))
764    {
765        let (authority, path) = rest.split_once('/')?;
766        let host = authority.rsplit('@').next().unwrap_or(authority);
767        return Some((host.to_string(), path.to_string()));
768    }
769    if let Some(rest) = r.strip_prefix("ssh://") {
770        let rest = rest.rsplit('@').next().unwrap_or(rest);
771        let (host, path) = rest.split_once('/')?;
772        return Some((host.to_string(), path.to_string()));
773    }
774    if let Some(rest) = r.strip_prefix("git@") {
775        let (host, path) = rest.split_once(':')?;
776        return Some((host.to_string(), path.to_string()));
777    }
778    None
779}
780
781/// Parse a git remote URL into `(host, slug)` where `slug` is the `owner/repo` path (subgroups
782/// preserved). Handles `https://host/owner/repo(.git)` and scp-style `git@host:owner/repo(.git)`
783/// plus `ssh://git@host/owner/repo`. Returns `None` when it can't be decomposed.
784fn parse_remote_host_slug(remote: &str) -> Option<(String, String)> {
785    let (host, path) = split_git_remote(remote.trim())?;
786    let path = path.trim().trim_end_matches('/');
787    let path = path.strip_suffix(".git").unwrap_or(path);
788    if host.is_empty() || !path.contains('/') {
789        return None;
790    }
791    Some((host, path.to_string()))
792}
793
794/// Derive a best-effort profile / contributions URL for a contributor from the repo's git remote.
795/// A GitHub "noreply" commit email embeds the account login, yielding an exact profile link;
796/// otherwise we link to the host's commit history filtered by this author. Only the three public
797/// hosts (github.com, gitlab.com, bitbucket.org) are linked, so a hostile remote can never coerce
798/// a link to an arbitrary domain. Returns `None` when nothing reliable can be built.
799fn author_profile_url(remote_url: Option<&str>, name: &str, email: &str) -> Option<String> {
800    if let Some(local) = email.strip_suffix("@users.noreply.github.com") {
801        let login = local.rsplit('+').next().unwrap_or(local);
802        if !login.is_empty() {
803            return Some(format!("https://github.com/{login}"));
804        }
805    }
806    let (host, slug) = parse_remote_host_slug(remote_url?)?;
807    let on = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
808    let email_q = url_query_encode(email);
809    let name_q = url_query_encode(name);
810    if on("github.com") {
811        Some(format!("https://{host}/{slug}/commits?author={email_q}"))
812    } else if on("gitlab.com") {
813        Some(format!("https://{host}/{slug}/-/commits?author={name_q}"))
814    } else if on("bitbucket.org") {
815        Some(format!("https://{host}/{slug}/commits/?author={email_q}"))
816    } else {
817        None
818    }
819}
820
821/// Static CSS for the Code Ownership page. Mirrors the canonical tokens/components from
822/// `/test-metrics` (single-brace; interpolated as an opaque value, never through `format!`).
823fn ownership_page_css() -> &'static str {
824    r#":root{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.82);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08878;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}
825body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
826*{box-sizing:border-box;} 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);} body{display:flex;flex-direction:column;}
827.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
828.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
829.code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
830.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
831@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));}}
832.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);}
833.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
834.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));}
835.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
836.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;}
837.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
838@media (max-width:1150px){.brand-subtitle{display:none;}}
839.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;}
840.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
841.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;} .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
842.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
843.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
844.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;}
845.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-wrap:hover .server-status-tip{display:block;}
846.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:175px;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;}
847.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:240px;max-width:300px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
848.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
849.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);}
850.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;} .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
851.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;}
852.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
853.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;}
854.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);}
855.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;}
856.page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
857.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}
858h1{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
859.muted{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;} code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:5px;}
860.summary-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin-bottom:18px;}
861@media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
862.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);}
863.stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
864.stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
865.stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
866.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);}
867.chart-box{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:18px;}
868.own-chart-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px;}
869.chart-box-title{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;}
870.own-control-groups{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
871.own-controls{display:inline-flex;gap:6px;background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:3px;}
872.own-filter,.own-scope{border:none;background:none;color:var(--muted);font-size:12px;font-weight:700;padding:5px 12px;border-radius:999px;cursor:pointer;transition:background .15s ease,color .15s ease;}
873.own-filter:hover,.own-scope:hover{color:var(--text);} .own-filter.active,.own-scope.active{background:var(--oxide);color:#fff;}
874.own-bar-row{display:grid;grid-template-columns:180px 1fr 78px;align-items:center;gap:12px;padding:5px 0;}
875.own-bar-name{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
876.own-bar-track{height:14px;border-radius:7px;background:var(--surface-2);overflow:hidden;}
877.own-bar-fill{height:100%;border-radius:7px;transition:width .35s ease;min-width:2px;}
878.own-bar-val{font-size:12px;font-weight:700;text-align:right;font-variant-numeric:tabular-nums;color:var(--muted);}
879@media(max-width:700px){.own-bar-row{grid-template-columns:120px 1fr 60px;}}
880.data-table{width:100%;border-collapse:collapse;font-size:13px;}
881.data-table th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:10px 12px;border-bottom:2px solid var(--line);white-space:nowrap;}
882.data-table td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle;}
883.data-table tr:last-child td{border-bottom:none;}
884.data-table tbody tr:hover td{background:var(--surface-2);}
885.num{text-align:right!important;font-variant-numeric:tabular-nums;}
886.own-email{color:var(--muted);font-size:12px;}
887.own-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:middle;}
888.own-empty{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;color:var(--muted-2);padding:40px 20px;}
889.own-empty svg{opacity:0.4;} .own-empty-title{font-size:16px;font-weight:800;color:var(--text);}
890.own-code{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;color:var(--oxide-2);}
891.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;} .site-footer a{color:var(--oxide-2);text-decoration:none;} .site-footer a:hover{text-decoration:underline;}
892.merge-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(325px,1fr));gap:11px;margin-bottom:14px;}
893.merge-opt{display:flex;align-items:center;gap:11px;padding:13px 17px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);cursor:pointer;transition:transform .22s cubic-bezier(.16,1,.3,1),border-color .18s ease,background .18s ease,box-shadow .22s ease;}
894.merge-opt:hover{border-color:var(--oxide);background:var(--surface);transform:translateY(-3px) scale(1.02);box-shadow:0 12px 28px rgba(77,44,20,0.18);}
895.merge-opt:hover .merge-opt-dot{transform:scale(1.35);box-shadow:0 0 0 4px rgba(196,92,16,0.15);}
896.merge-opt:hover .merge-opt-name{color:var(--oxide-2);}
897.merge-opt:active{transform:translateY(-1px) scale(1.0);}
898.merge-opt input{accent-color:var(--oxide);width:17px;height:17px;flex:0 0 auto;cursor:pointer;}
899.merge-opt-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;transition:transform .2s ease,box-shadow .2s ease;}
900.merge-opt-name{font-size:15px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
901.merge-opt-email{font-size:12px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;}
902.merge-controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;}
903.merge-name-input{flex:1 1 240px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);color:var(--text);font-size:13px;outline:none;}
904.merge-name-input:focus{border-color:var(--oxide);}
905.merge-btn{padding:9px 18px;border:none;border-radius:10px;background:var(--oxide);color:#fff;font-size:13px;font-weight:800;cursor:pointer;transition:background .15s ease,transform .15s ease;}
906.merge-btn:hover{background:var(--oxide-2);transform:translateY(-1px);}
907.merge-mailmap-link{font-size:12px;font-weight:700;color:var(--oxide-2);text-decoration:none;}
908.merge-mailmap-link:hover{text-decoration:underline;}
909.merge-existing{margin-top:16px;border-top:1px solid var(--line);padding-top:12px;}
910.merge-existing-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px;}
911.merge-chip{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);margin-bottom:6px;}
912.merge-chip-text{font-size:12px;color:var(--muted);flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
913.merge-chip-text strong{color:var(--text);font-size:13px;}
914.merge-unmerge{padding:5px 12px;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;}
915.merge-unmerge:hover{background:var(--surface-2);border-color:var(--oxide);color:var(--oxide-2);}
916.merge-mailmap-note{margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-left:3px solid var(--oxide);border-radius:8px;padding:10px 13px;}
917.merge-mailmap-note strong{color:var(--text);}
918.own-project-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 18px;}
919.own-project-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}
920.own-project-select{padding:9px 34px 9px 13px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;outline:none;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237b675b' stroke-width='2.5'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;min-width:220px;transition:border-color .15s ease;}
921.own-project-select:hover{border-color:var(--oxide);} .own-project-select:focus{border-color:var(--oxide);}
922.own-project-hint{font-size:12px;color:var(--muted-2);}
923.own-bar-row{position:relative;border-radius:8px;transition:transform .22s cubic-bezier(.16,1,.3,1),background .22s ease;}
924.own-bar-row:hover{transform:translateX(6px);background:var(--surface-2);z-index:6;}
925.own-bar-row:hover .own-bar-fill{filter:brightness(1.08) saturate(1.12);box-shadow:0 3px 12px rgba(0,0,0,.22);}
926.own-bar-row:hover .own-bar-val{color:var(--oxide);}
927.own-bar-fill{transition:width .35s ease,filter .2s ease,box-shadow .2s ease;}
928.own-bar-name a.own-profile-link{color:inherit;text-decoration:none;}
929.own-bar-name a.own-profile-link:hover{color:var(--oxide);text-decoration:underline;}
930.own-profile-link{color:var(--oxide-2);text-decoration:none;font-weight:inherit;}
931.own-profile-link:hover{text-decoration:underline;}
932.stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(4px);background:var(--text);color:var(--bg);padding:9px 13px;border-radius:9px;font-size:11.5px;font-weight:600;line-height:1.5;width:max-content;max-width:280px;pointer-events:none;opacity:0;transition:opacity .2s ease,transform .2s ease;z-index:200;box-shadow:0 10px 28px rgba(0,0,0,.28);}
933.stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:6px solid transparent;border-bottom-color:var(--text);}
934.stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
935.own-charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px;}
936@media(max-width:900px){.own-charts-grid{grid-template-columns:1fr;}}
937.own-canvas-wrap{position:relative;width:100%;height:300px;margin-top:8px;}
938.own-canvas-empty{color:var(--muted-2);font-size:13px;padding:28px 4px;text-align:center;}
939.own-canvas-note{color:var(--muted-2);font-size:11.5px;margin-top:8px;text-align:center;}
940.own-legend{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:12px;}
941.own-legend-item{display:inline-flex;align-items:center;gap:7px;background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:5px 13px;font-size:12px;font-weight:700;color:var(--text);cursor:pointer;transition:transform .18s cubic-bezier(.16,1,.3,1),box-shadow .18s ease,border-color .18s ease,background .18s ease;}
942.own-legend-item:hover,.own-legend-item.active{transform:translateY(-4px) scale(1.07);box-shadow:0 10px 22px rgba(77,44,20,0.22);border-color:var(--oxide);background:var(--surface);}
943.own-legend-item:active{transform:translateY(-1px) scale(1.02);}
944.own-legend-swatch{width:12px;height:12px;border-radius:3px;flex:0 0 auto;transition:transform .18s ease;}
945.own-legend-item:hover .own-legend-swatch,.own-legend-item.active .own-legend-swatch{transform:scale(1.3);}
946.own-legend-label{white-space:nowrap;}
947.own-legend-modal{margin-top:16px;}
948#own-bars{max-height:540px;overflow-y:auto;overflow-x:clip;padding-right:4px;}
949#own-bars::-webkit-scrollbar{width:9px;} #own-bars::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:6px;} #own-bars::-webkit-scrollbar-track{background:transparent;}
950.own-table-hint{font-size:12px;color:var(--muted);margin:-4px 0 10px;line-height:1.55;} .own-table-hint strong{color:var(--text);} .own-table-hint em{color:var(--oxide-2);font-style:normal;font-weight:700;}
951.own-email-copy{font-family:inherit;font-size:12px;color:var(--muted);background:none;border:none;padding:2px 6px;margin:-2px -6px;border-radius:6px;cursor:pointer;text-align:left;transition:background .15s ease,color .15s ease;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
952.own-email-copy:hover{background:var(--surface-2);color:var(--oxide-2);}
953.own-email-copy.copied{color:#2a6846;} body.dark-theme .own-email-copy.copied{color:#5aba8a;}
954.own-footnote{padding:16px 18px;} .own-footnote .muted{margin:0;}
955.own-copy-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(10px);background:var(--text);color:var(--bg);padding:10px 18px;border-radius:10px;font-size:13px;font-weight:700;box-shadow:0 12px 32px rgba(0,0,0,.3);z-index:9999;opacity:0;transition:opacity .2s ease,transform .2s ease;pointer-events:none;}
956.own-copy-toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
957.own-float-tip{position:fixed;top:0;left:0;min-width:230px;max-width:340px;background:var(--surface);border:1px solid var(--line-strong);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.28);padding:12px 14px;pointer-events:none;opacity:0;transform:scale(.97);transition:opacity .12s ease,transform .12s ease;z-index:9999;}
958.own-float-tip.show{opacity:1;transform:scale(1);}
959.own-bar-tip-head{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:800;color:var(--text);margin-bottom:2px;}
960.own-bar-tip-swatch{width:11px;height:11px;border-radius:50%;flex:0 0 auto;}
961.own-bar-tip-email{font-size:11.5px;color:var(--muted);margin-bottom:8px;word-break:break-all;}
962.own-bar-tip-cat{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--oxide);}
963.own-bar-tip-desc{font-size:12px;line-height:1.55;color:var(--muted);margin:5px 0 9px;}
964.own-bar-tip-num{font-size:19px;font-weight:900;color:var(--text);font-variant-numeric:tabular-nums;}
965.own-bar-tip-num span{font-size:11px;font-weight:700;color:var(--muted-2);text-transform:uppercase;letter-spacing:.05em;margin-left:3px;}
966.own-charts-sub{margin:0 0 12px;}
967.chart-box .toolbar{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;align-items:center;margin-bottom:6px;}
968.chart-box .toolbar-left{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
969.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;}
970.chart-expand-btn:hover{background:var(--surface-2);color:var(--text);}
971.own-canvas-wrap-donut{height:340px;}
972.chart-select{padding:6px 10px;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}
973.chart-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
974.chart-modal{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1500px;width:100%;max-height:92vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
975body.dark-theme .chart-modal{background:var(--surface);}
976.chart-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
977.chart-modal-title{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0;flex:1 1 auto;min-width:0;}
978.chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;}
979.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;}
980.chart-modal-close:hover{opacity:.7;}
981.own-table-scroll{overflow-x:auto;}
982.own-contrib-table{table-layout:fixed;}
983.own-contrib-table th{position:relative;cursor:pointer;user-select:none;overflow:hidden;text-overflow:ellipsis;}
984.own-contrib-table th .own-sort-ind{margin-left:5px;font-size:9px;color:var(--muted-2);}
985.own-contrib-table th.own-sorted{color:var(--oxide);}
986.own-contrib-table td{overflow:hidden;text-overflow:ellipsis;}
987.own-col-resizer{position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;user-select:none;touch-action:none;}
988.own-col-resizer:hover{background:var(--oxide);opacity:.35;}
989body.own-resizing{cursor:col-resize;user-select:none;}
990.own-page-intro{margin:0 0 16px;}
991.lang-cell{display:inline-flex;align-items:center;gap:9px;}
992.lang-badge{display:inline-flex;flex:0 0 auto;line-height:0;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.18));}
993.lang-badge svg{display:block;border-radius:5px;}
994.lang-cell-name{font-weight:600;}
995.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-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
996.btn:hover{background:var(--line);}
997.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:16px;position:relative;z-index:1;}
998.watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
999.watched-bar-left>svg{color:var(--muted);flex-shrink:0;}
1000.watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
1001.watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
1002.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:340px;}
1003.watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1004.watched-chip form{display:inline;margin:0;}
1005.watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
1006.watched-chip-rm:hover{color:var(--oxide);}
1007.watched-none{font-size:12px;color:var(--muted);font-style:italic;}
1008.watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
1009.watched-bar-right form{display:inline;margin:0;}
1010.watched-bar-right .btn{box-sizing:border-box;height:30px;}
1011body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}"#
1012}
1013
1014/// Static nav bar for the Code Ownership page (Git Browser dropdown active, since ownership
1015/// lives under it). Copied from the canonical nav so the page matches every other surface.
1016fn ownership_page_nav() -> &'static str {
1017    r#"<div class="top-nav"><div class="top-nav-inner">
1018    <a class="brand" href="/"><img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo"><div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Code Ownership</div></div></a>
1019    <div class="nav-right">
1020      <a class="nav-pill" href="/">Home</a>
1021      <div class="nav-dropdown">
1022        <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>
1023        <div class="nav-dropdown-menu"><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></div>
1024      </div>
1025      <a class="nav-pill" href="/compare-scans">Compare Scans</a>
1026      <a class="nav-pill" href="/test-metrics">Test Metrics</a>
1027      <div class="nav-dropdown">
1028        <a href="/git-browser" class="nav-dropdown-btn sx-8c38ef73" >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>
1029        <div class="nav-dropdown-menu">
1030          <a class="sx-ee1cc7d2" href="/code-ownership" ><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
1031          <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>
1032        </div>
1033      </div>
1034      <div class="server-status-wrap" id="server-status-wrap">
1035        <div class="nav-pill server-online-pill" id="server-status-pill"><span class="status-dot" id="status-dot"></span><span id="server-status-label">Server</span><span class="sx-d60f2ef3" id="server-ping-ms" ></span></div>
1036        <div class="server-status-tip">OxideSLOC is running &mdash; accessible on your network.<span class="sx-238af6bc" id="server-tip-ping" ></span></div>
1037      </div>
1038      <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings"><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 1 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 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.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 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.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 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.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></button>
1039      <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"><svg class="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></button>
1040    </div>
1041  </div></div>"#
1042}
1043
1044/// Static client scripts for the Code Ownership page: theme toggle, watermark/particle spawn,
1045/// settings modal, server-status ping, and the bar-chart category filter.
1046fn ownership_page_scripts() -> &'static str {
1047    r#"(function(){
1048  var b=document.body;
1049  try{if(localStorage.getItem('oxide-theme')==='dark')b.classList.add('dark-theme');}catch(e){}
1050  var tgl=document.getElementById('theme-toggle');
1051  if(tgl)tgl.addEventListener('click',function(){var d=b.classList.toggle('dark-theme');try{localStorage.setItem('oxide-theme',d?'dark':'light');}catch(e){}});
1052})();
1053(function(){
1054  var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
1055  if(!wms.length)return;var placed=[];
1056  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;}
1057  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];}
1058  var half=Math.floor(wms.length/2);
1059  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;});
1060})();
1061(function(){
1062  var container=document.getElementById('code-particles');if(!container)return;
1063  var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
1064  for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snippets[idx%snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));var left=Math.random()*94+2,top=Math.random()*88+6,dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random() * 0.108 + 0.072).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);}
1065})();
1066(function(){
1067  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'}];
1068  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);});}
1069  try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
1070  var btn=document.getElementById('settings-btn');if(!btn)return;
1071  var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
1072  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>';
1073  document.body.appendChild(m);
1074  var g=document.getElementById('scheme-grid');
1075  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);el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
1076  var cl=document.getElementById('settings-close');
1077  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');});
1078  if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
1079  document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
1080})();
1081(function(){
1082  var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping');
1083  if(location.protocol==='file:')return;
1084  function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';}else if(ms<300){dot.style.background='#f5a623';}else{dot.style.background='#e05c5c';}}
1085  function ping(){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='';});}
1086  ping();setInterval(ping,5000);
1087})();
1088(function(){
1089  var el=document.getElementById('own-data');if(!el)return;
1090  var data;try{data=JSON.parse(el.textContent);}catch(e){return;}
1091  var wrap=document.getElementById('own-bars');if(!wrap)return;
1092  var btns=document.querySelectorAll('.own-filter');
1093  var scopeBtns=document.querySelectorAll('.own-scope');
1094  var CAT={
1095    code:{label:'Code lines',desc:'Physical lines that contain executable source code. Comment-only and blank lines are excluded.'},
1096    comment:{label:'Comment lines',desc:'Physical lines that are entirely comments or documentation.'},
1097    blank:{label:'Blank lines',desc:'Empty or whitespace-only lines separating code.'},
1098    total:{label:'Total lines',desc:'Every physical line this contributor last touched \u2014 code, comments and blanks combined.'}
1099  };
1100  var SCOPE_LABEL={all:'',dev:' (development files)',test:' (test files)'};
1101  var activeMetric='code',activeScope='all';
1102  function val(d,metric,scope){
1103    var base=Number(d[metric]||0);
1104    if(scope==='test')return Number(d['test_'+metric]||0);
1105    if(scope==='dev')return Math.max(0,base-Number(d['test_'+metric]||0));
1106    return base;
1107  }
1108  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();}
1109  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
1110  // Single cursor-following tooltip, reused across renders and rows.
1111  var tip=document.createElement('div');tip.className='own-float-tip';document.body.appendChild(tip);
1112  var curRow=null;
1113  function fillTip(d){
1114    var cat=CAT[activeMetric]||CAT.code;
1115    tip.innerHTML='<div class="own-bar-tip-head"><span class="own-bar-tip-swatch"></span><span class="tn"></span></div>'
1116      +'<div class="own-bar-tip-email"></div>'
1117      +'<div class="own-bar-tip-cat"></div>'
1118      +'<div class="own-bar-tip-desc"></div>'
1119      +'<div class="own-bar-tip-num"><span class="nv"></span> <span>lines</span></div>';
1120    tip.querySelector('.own-bar-tip-swatch').style.background=d.color;
1121    tip.querySelector('.tn').textContent=d.name;
1122    tip.querySelector('.own-bar-tip-email').textContent=d.email||'';
1123    tip.querySelector('.own-bar-tip-cat').textContent=cat.label+(SCOPE_LABEL[activeScope]||'');
1124    tip.querySelector('.own-bar-tip-desc').textContent=cat.desc;
1125    tip.querySelector('.nv').textContent=val(d,activeMetric,activeScope).toLocaleString();
1126  }
1127  function moveTip(e){
1128    var x=e.clientX+16,y=e.clientY+16,w=tip.offsetWidth,h=tip.offsetHeight;
1129    if(x+w>window.innerWidth-8)x=e.clientX-w-16;
1130    if(y+h>window.innerHeight-8)y=e.clientY-h-16;
1131    if(x<8)x=8;if(y<8)y=8;
1132    tip.style.left=x+'px';tip.style.top=y+'px';
1133  }
1134  wrap.addEventListener('mousemove',function(e){
1135    var row=e.target.closest('.own-bar-row');
1136    if(!row||!row._d){tip.classList.remove('show');curRow=null;return;}
1137    if(row!==curRow){curRow=row;fillTip(row._d);tip.classList.add('show');}
1138    moveTip(e);
1139  });
1140  wrap.addEventListener('mouseleave',function(){tip.classList.remove('show');curRow=null;});
1141  function render(){
1142    var metric=activeMetric,scope=activeScope;
1143    var max=1;data.forEach(function(d){var v=val(d,metric,scope);if(v>max)max=v;});
1144    var rows=data.slice().sort(function(x,y){return val(y,metric,scope)-val(x,metric,scope);});
1145    var html='';
1146    rows.forEach(function(d){
1147      var v=val(d,metric,scope);
1148      var pct=Math.round(v/max*100);
1149      var nm=d.profile?('<a class="own-profile-link" href="'+esc(d.profile)+'" target="_blank" rel="noopener">'+esc(d.name)+'</a>'):esc(d.name);
1150      html+='<div class="own-bar-row">'
1151        +'<div class="own-bar-name" title="'+esc(d.email)+'">'+nm+'</div>'
1152        +'<div class="own-bar-track"><div class="own-bar-fill" data-sx-style="width:'+pct+'%;background:'+d.color+';"></div></div>'
1153        +'<div class="own-bar-val">'+fmt(v)+'</div>'
1154      +'</div>';
1155    });
1156    wrap.innerHTML=html;
1157    if(window.sxApply)window.sxApply(wrap);
1158    var els=wrap.querySelectorAll('.own-bar-row');
1159    for(var i=0;i<els.length;i++){els[i]._d=rows[i];}
1160    curRow=null;tip.classList.remove('show');
1161  }
1162  btns.forEach(function(bn){bn.addEventListener('click',function(){btns.forEach(function(x){x.classList.remove('active');});bn.classList.add('active');activeMetric=bn.getAttribute('data-metric');render();});});
1163  scopeBtns.forEach(function(bn){bn.addEventListener('click',function(){scopeBtns.forEach(function(x){x.classList.remove('active');});bn.classList.add('active');activeScope=bn.getAttribute('data-scope');render();document.dispatchEvent(new CustomEvent('own-scope-change',{detail:activeScope}));});});
1164  render();
1165})();
1166(function(){
1167  var s=document.getElementById('own-project-select');if(!s)return;
1168  s.addEventListener('change',function(){var v=s.value;window.location=v?('/code-ownership?project='+encodeURIComponent(v)):'/code-ownership';});
1169})();
1170(function(){
1171  // Click any contributor email in the table to copy it to the clipboard.
1172  var toast=document.getElementById('own-copy-toast');var toastT=null;
1173  function showToast(msg){if(!toast)return;toast.textContent=msg;toast.hidden=false;toast.classList.add('show');if(toastT)clearTimeout(toastT);toastT=setTimeout(function(){toast.classList.remove('show');},1600);}
1174  function copy(text,btn){
1175    function ok(){if(btn){btn.classList.add('copied');setTimeout(function(){btn.classList.remove('copied');},1200);}showToast('Copied '+text);}
1176    if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(ok).catch(function(){fallback(text,ok);});}
1177    else{fallback(text,ok);}
1178  }
1179  function fallback(text,ok){try{var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok();}catch(e){showToast('Copy failed');}}
1180  document.addEventListener('click',function(e){var b=e.target.closest('.own-email-copy');if(!b)return;var em=b.getAttribute('data-email');if(em)copy(em,b);});
1181})();
1182(function(){
1183  if(typeof Chart==='undefined')return;
1184  var el=document.getElementById('own-data');if(!el)return;
1185  var data;try{data=JSON.parse(el.textContent);}catch(e){return;}
1186  var comp=document.getElementById('own-canvas-composition'),share=document.getElementById('own-canvas-share');
1187  var FONT='Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif';
1188  // Canonical report palette + helpers (mirrors sloc-report result charts).
1189  var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
1190  var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
1191  var TOPN=12,chartScope='all';
1192  function isDark(){return document.body.classList.contains('dark-theme');}
1193  function clr(){return isDark()?{text:'#d4c5b8',grid:'rgba(255,255,255,0.10)',surface:'#261c17'}:{text:'#43342d',grid:'#e6d0bf',surface:'#ffffff'};}
1194  function hexAlpha(hex,a){var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);return 'rgba('+r+','+g+','+b+','+a+')';}
1195  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 Math.round(v/1e3)+'K';return v.toLocaleString();}
1196  function chartCursor(e,els){var t=e.native&&e.native.target;if(t)t.style.cursor=els.length?'pointer':'default';}
1197  function legendCursorOn(e){var t=e.native&&e.native.target;if(t)t.style.cursor='pointer';}
1198  function legendCursorOff(e){var t=e.native&&e.native.target;if(t)t.style.cursor='default';}
1199  // Cursor-following tooltip positioner so the composition tooltip tracks the mouse (and can be
1200  // nudged off the bar) instead of pinning to the bar centre and blocking it.
1201  if(Chart.Tooltip&&Chart.Tooltip.positioners&&!Chart.Tooltip.positioners.ownCursor){
1202    Chart.Tooltip.positioners.ownCursor=function(items,evtPos){return evtPos?{x:evtPos.x,y:evtPos.y}:false;};
1203  }
1204  var BLANK=function(){return isDark()?'#524238':'#e6d0bf';};
1205  var BLANK_H=function(){return isDark()?'#6b5548':'#d8bfad';};
1206  function sval(d,metric,scope){var base=Number(d[metric]||0);if(scope==='test')return Number(d['test_'+metric]||0);if(scope==='dev')return Math.max(0,base-Number(d['test_'+metric]||0));return base;}
1207  function buildView(scope){
1208    var arr=data.map(function(d,i){return {name:d.name,color:d.color||PALETTE[i%PALETTE.length],profile:d.profile,
1209      code:sval(d,'code',scope),comment:sval(d,'comment',scope),blank:sval(d,'blank',scope)};});
1210    arr.sort(function(a,b){return b.code-a.code;});
1211    if(arr.length>TOPN){
1212      var head=arr.slice(0,TOPN),rest=arr.slice(TOPN);
1213      var o={name:'Others ('+rest.length+')',color:(isDark()?'#6b5548':'#c9b3a1'),profile:null,code:0,comment:0,blank:0};
1214      rest.forEach(function(r){o.code+=r.code;o.comment+=r.comment;o.blank+=r.blank;});
1215      head.push(o);
1216      return {view:head,truncated:rest.length};
1217    }
1218    return {view:arr,truncated:0};
1219  }
1220  function setNote(id,trunc){var n=document.getElementById(id);if(!n)return;if(trunc>0){n.textContent='Showing top '+TOPN+' contributors; '+trunc+' more grouped as "Others".';n.hidden=false;}else{n.hidden=true;}}
1221  function openProfileFrom(view,i){var p=view[i]&&view[i].profile;if(p)window.open(p,'_blank','noopener');}
1222  // Plugin: value label inside each visible stacked-bar segment (report segLabelPlugin).
1223  var segLabelPlugin={afterDatasetsDraw:function(chart){var ctx=chart.ctx,nDs=chart.data.datasets.length;for(var di=0;di<nDs;di++){var meta=chart.getDatasetMeta(di);if(meta.hidden)continue;meta.data.forEach(function(elm,idx){var v=chart.data.datasets[di].data[idx]||0;if(!v)return;var w=Math.abs(elm.x-elm.base);if(w<28)return;ctx.save();ctx.font='600 10px '+FONT;ctx.fillStyle=(di===2)?(isDark()?'#f0e6dc':'#555'):'#fff';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(fmt(v),elm.base+w/2,elm.y);ctx.restore();});}}};
1224  // Plugin: row total at the end of the stacked bar (report makeStackedEndPlugin).
1225  function makeStackedEndPlugin(){return {afterDatasetsDraw:function(chart){var ctx=chart.ctx,tc=clr().text,nDs=chart.data.datasets.length;if(!nDs)return;var lastMeta=chart.getDatasetMeta(nDs-1);lastMeta.data.forEach(function(elm,idx){var total=0;chart.data.datasets.forEach(function(ds){total+=ds.data[idx]||0;});if(!total)return;ctx.save();ctx.font='600 11px '+FONT;ctx.fillStyle=tc;ctx.textAlign='left';ctx.textBaseline='middle';ctx.fillText(fmt(total),elm.x+5,elm.y);ctx.restore();});}};}
1226  // Pick readable text color for a label drawn on a colored slice.
1227  function textOn(bg){if(bg&&bg.charAt(0)==='#'&&bg.length>=7){var r=parseInt(bg.slice(1,3),16),g=parseInt(bg.slice(3,5),16),b=parseInt(bg.slice(5,7),16);var lum=0.299*r+0.587*g+0.114*b;return lum>150?'#3a2a20':'#fff';}return '#fff';}
1228  // Plugin: permanent on-slice name + % labels, plus a centered total in the hole.
1229  var donutLabelPlugin={afterDatasetsDraw:function(chart){
1230    if(chart.config.type!=='doughnut')return;
1231    var ctx=chart.ctx,meta=chart.getDatasetMeta(0),ds=chart.data.datasets[0];
1232    var tot=ds.data.reduce(function(a,b){return a+Number(b);},0)||1;
1233    meta.data.forEach(function(arc,i){
1234      var v=Number(ds.data[i]||0);if(!v)return;
1235      var pct=v/tot*100,sweep=arc.endAngle-arc.startAngle;
1236      var ang=(arc.startAngle+arc.endAngle)/2,r=(arc.innerRadius+arc.outerRadius)/2;
1237      var x=arc.x+Math.cos(ang)*r,y=arc.y+Math.sin(ang)*r;
1238      ctx.save();ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=textOn(ds.backgroundColor[i]);
1239      if(sweep>0.34){ctx.font='700 12px '+FONT;ctx.fillText(chart.data.labels[i],x,y-8);ctx.font='800 13px '+FONT;ctx.fillText((pct>=10?pct.toFixed(0):pct.toFixed(1))+'%',x,y+9);}
1240      else if(sweep>0.13){ctx.font='800 12px '+FONT;ctx.fillText(pct.toFixed(0)+'%',x,y);}
1241      ctx.restore();
1242    });
1243    var m0=meta.data[0];if(m0){var cc=clr();ctx.save();ctx.textAlign='center';ctx.textBaseline='middle';
1244      ctx.fillStyle=cc.text;ctx.font='800 20px '+FONT;ctx.fillText(fmt(tot),m0.x,m0.y-7);
1245      ctx.fillStyle=isDark()?'#b09080':'#7b675b';ctx.font='600 11px '+FONT;ctx.fillText('code lines',m0.x,m0.y+13);
1246      ctx.restore();}
1247  }};
1248  var charts=[];
1249  function destroy(){charts.forEach(function(c){try{c.destroy();}catch(e){}});charts=[];}
1250  if(!data.length){
1251    ['own-comp-wrap','own-share-wrap'].forEach(function(id){var w=document.getElementById(id);if(w)w.hidden=true;});
1252    ['own-comp-empty','own-share-empty'].forEach(function(id){var e2=document.getElementById(id);if(e2)e2.hidden=false;});
1253    var cb=document.getElementById('own-comp-expand'),sb=document.getElementById('own-share-expand');if(cb)cb.style.display='none';if(sb)sb.style.display='none';
1254    return;
1255  }
1256  // Restore a chart's slices/datasets to their base colours (clears any legend-hover dim/pop).
1257  function restoreChart(ch){
1258    if(!ch||!ch.data)return;
1259    ch.data.datasets.forEach(function(dst){
1260      if(dst._baseBg){dst.backgroundColor=dst._baseBg.slice();}
1261      else if(dst._base){dst.backgroundColor=dst._base;dst.hoverBackgroundColor=dst._baseHover;}
1262    });
1263    ch.setActiveElements([]);if(ch.tooltip)ch.tooltip.setActiveElements([],{});ch.update('none');
1264  }
1265  // Emphasise one legend entry: dim the others, pop the matching element, show its tooltip.
1266  function highlight(ch,kind,idx){
1267    if(kind==='datasets'){
1268      ch.data.datasets.forEach(function(dst,i){var base=dst._base;var faded=(base&&base.charAt(0)==='#'&&base.length===7)?hexAlpha(base,0.15):hexAlpha('#888888',0.15);dst.backgroundColor=i===idx?base:faded;dst.hoverBackgroundColor=dst.backgroundColor;});
1269      var n=ch.data.datasets.length,ae=[];for(var ii=0;ii<n;ii++){ae.push({datasetIndex:ii,index:0});}
1270      var fp=ch.getDatasetMeta(idx).data[0];ch.setActiveElements([{datasetIndex:idx,index:0}]);if(ch.tooltip)ch.tooltip.setActiveElements(ae,fp?{x:fp.x,y:fp.y}:{x:0,y:0});ch.update();
1271    }else{
1272      var bg=ch.data.datasets[0]._baseBg||ch.data.datasets[0].backgroundColor;
1273      ch.data.datasets[0].backgroundColor=bg.map(function(col,j){return j===idx?col:hexAlpha(col.charAt(0)==='#'&&col.length===7?col:'#888888',0.2);});
1274      var fp2=ch.getDatasetMeta(0).data[idx];ch.setActiveElements([{datasetIndex:0,index:idx}]);var pos=fp2&&fp2.tooltipPosition?fp2.tooltipPosition():(fp2?{x:fp2.x,y:fp2.y}:{x:0,y:0});if(ch.tooltip)ch.tooltip.setActiveElements([{datasetIndex:0,index:idx}],pos);ch.update();
1275    }
1276  }
1277  // Build the interactive HTML legend for a chart. Real mouseenter/leave events give a reliable
1278  // reset (canvas legends leave the dim state stuck) and let the legend items CSS-animate on hover.
1279  function buildLegend(ch,el,kind){
1280    if(!el)return;el.innerHTML='';
1281    var items;
1282    if(kind==='datasets'){
1283      items=ch.data.datasets.map(function(d,i){return {label:d.label,color:d._base||d.backgroundColor,idx:i,profile:null};});
1284    }else{
1285      var ds0=ch.data.datasets[0],bg=ds0._baseBg||ds0.backgroundColor,tot=ds0.data.reduce(function(a,b){return a+Number(b);},0)||1,view=ch.$view||[];
1286      items=ch.data.labels.map(function(lb,i){var pct=Number(ds0.data[i]||0)/tot*100;return {label:lb+'  '+(pct>=10?pct.toFixed(0):pct.toFixed(1))+'%',color:bg[i],idx:i,profile:view[i]&&view[i].profile};});
1287    }
1288    items.forEach(function(it){
1289      var b=document.createElement('button');b.type='button';b.className='own-legend-item'+(it.profile?' has-profile':'');
1290      var sw=document.createElement('span');sw.className='own-legend-swatch';sw.style.setProperty('background',it.color);
1291      var tx=document.createElement('span');tx.className='own-legend-label';tx.textContent=it.label;
1292      b.appendChild(sw);b.appendChild(tx);
1293      // Highlight on enter/focus. The reset lives on the CONTAINER's mouseleave (below), not per
1294      // item: the item pops up on hover (transform), which can slip out from under the cursor and
1295      // miss a per-item mouseleave — leaving the chart stuck dimmed. Container mouseleave is reliable.
1296      b.addEventListener('mouseenter',function(){highlight(ch,kind,it.idx);});
1297      b.addEventListener('focus',function(){b.classList.add('active');highlight(ch,kind,it.idx);});
1298      b.addEventListener('blur',function(){b.classList.remove('active');restoreChart(ch);});
1299      if(it.profile)b.addEventListener('click',function(){window.open(it.profile,'_blank','noopener');});
1300      el.appendChild(b);
1301    });
1302    el.addEventListener('mouseleave',function(){restoreChart(ch);});
1303  }
1304  // ── Composition (stacked horizontal bar) config, shared by inline + Full View ──
1305  function compConfig(view,big){
1306    var c=clr();
1307    var cols=[OX,GN,BLANK()],hov=['#d97020','#3a8a5e',BLANK_H()];
1308    function ds(label,key,i){return {label:label,data:view.map(function(v){return v[key];}),backgroundColor:cols[i],hoverBackgroundColor:hov[i],_base:cols[i],_baseHover:hov[i],borderRadius:0,borderSkipped:false,maxBarThickness:big?380:150};}
1309    return {type:'bar',
1310      data:{labels:view.map(function(v){return v.name;}),datasets:[ds('Code','code',0),ds('Comments','comment',1),ds('Blank','blank',2)]},
1311      options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,
1312        onHover:chartCursor,
1313        animation:{duration:500,easing:'easeOutQuart'},transitions:{active:{animation:{duration:180,easing:'easeOutQuart'}}},
1314        layout:{padding:{right:56}},
1315        onClick:function(e,els){if(els&&els.length)openProfileFrom(view,els[0].index);},
1316        scales:{x:{stacked:true,grid:{color:c.grid},ticks:{color:c.text,callback:function(v){return fmt(v);}}},
1317                y:{stacked:true,grid:{display:false},ticks:{color:c.text}}},
1318        plugins:{
1319          legend:{display:false},
1320          tooltip:{mode:'index',position:'ownCursor',caretPadding:16,yAlign:'bottom',callbacks:{
1321            title:function(items){return items.length?items[0].label:'';},
1322            label:function(ctx){return '  '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
1323            footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}}}
1324        }},
1325      plugins:[makeStackedEndPlugin(),segLabelPlugin,{id:'compLeave',afterEvent:function(ch,a){if(a.event&&a.event.type==='mouseout')restoreChart(ch);}}]};
1326  }
1327  // ── Share (doughnut) config, shared by inline + Full View ──
1328  function shareConfig(view,big){
1329    var c=clr();
1330    var baseBg=view.map(function(v){return v.color;});
1331    return {type:'doughnut',
1332      data:{labels:view.map(function(v){return v.name;}),datasets:[{data:view.map(function(v){return v.code;}),
1333        backgroundColor:baseBg.slice(),_baseBg:baseBg.slice(),borderColor:c.surface,borderWidth:2,hoverOffset:big?26:18,hoverBorderColor:c.surface}]},
1334      options:{responsive:true,maintainAspectRatio:false,cutout:'62%',radius:big?'96%':'92%',
1335        animation:{animateRotate:true,duration:600,easing:'easeOutQuart'},transitions:{active:{animation:{duration:220,easing:'easeOutQuart'}}},
1336        onHover:chartCursor,
1337        onClick:function(e,els){if(els&&els.length)openProfileFrom(view,els[0].index);},
1338        layout:{padding:{left:6,right:6,top:6,bottom:6}},
1339        plugins:{
1340          legend:{display:false},
1341          tooltip:{callbacks:{label:function(ctx){var t=ctx.dataset.data.reduce(function(a,b){return a+Number(b);},0);var p=t?(Number(ctx.raw)/t*100):0;return '  '+ctx.label+': '+Number(ctx.raw).toLocaleString()+' ('+p.toFixed(1)+'%)';}}}}},
1342      plugins:[donutLabelPlugin,{id:'shareLeave',afterEvent:function(ch,a){if(a.event&&a.event.type==='mouseout')restoreChart(ch);}}]};
1343  }
1344  function build(){
1345    destroy();
1346    var vt=buildView(chartScope),view=vt.view;
1347    setNote('own-comp-note',vt.truncated);setNote('own-share-note',vt.truncated);
1348    var cwrap=document.getElementById('own-comp-wrap');
1349    if(cwrap)cwrap.style.height=Math.min(640,Math.max(240,view.length*34+70))+'px';
1350    if(comp){var cc=new Chart(comp.getContext('2d'),compConfig(view));cc.$view=view;charts.push(cc);buildLegend(cc,document.getElementById('own-comp-legend'),'datasets');}
1351    if(share){var sc=new Chart(share.getContext('2d'),shareConfig(view));sc.$view=view;charts.push(sc);buildLegend(sc,document.getElementById('own-share-legend'),'slices');}
1352  }
1353  build();
1354  document.addEventListener('own-scope-change',function(e){chartScope=(e&&e.detail)||'all';build();});
1355  var tgl=document.getElementById('theme-toggle');
1356  if(tgl)tgl.addEventListener('click',function(){setTimeout(build,70);});
1357  // ── Full View modals (report makeOverlay) ──
1358  function makeOverlay(title,h){
1359    var overlay=document.createElement('div');overlay.className='chart-modal-overlay';
1360    var maxH=Math.max(520,Math.floor(window.innerHeight*0.9)-110);
1361    var hAttr='height:'+Math.min(h||620,maxH)+'px;';
1362    overlay.innerHTML='<div class="chart-modal"><button class="chart-modal-close" aria-label="Close">&times;</button><div class="chart-modal-header"><span class="chart-modal-title">'+title+'</span></div><div style="position:relative;width:100%;'+hAttr+'"><canvas id="own-modal-canvas"></canvas></div><div class="own-legend own-legend-modal" id="own-modal-legend"></div></div>';
1363    document.body.appendChild(overlay);
1364    function close(){if(overlay.parentNode)document.body.removeChild(overlay);document.removeEventListener('keydown',onKey);}
1365    function onKey(ev){if(ev.key==='Escape')close();}
1366    overlay.querySelector('.chart-modal-close').addEventListener('click',close);
1367    overlay.addEventListener('click',function(e){if(e.target===overlay)close();});
1368    document.addEventListener('keydown',onKey);
1369    return document.getElementById('own-modal-canvas');
1370  }
1371  function openModalChart(title,h,cfgFn,kind){var view=buildView(chartScope).view;var cv2=makeOverlay(title,h);if(cv2)requestAnimationFrame(function(){var mc=new Chart(cv2,cfgFn(view,true));mc.$view=view;buildLegend(mc,document.getElementById('own-modal-legend'),kind);});}
1372  var compBtn=document.getElementById('own-comp-expand');
1373  if(compBtn)compBtn.addEventListener('click',function(){var n=buildView(chartScope).view.length;openModalChart('Line composition per contributor \u2014 Full View',Math.min(1000,Math.max(640,n*92+260)),compConfig,'datasets');});
1374  var shareBtn=document.getElementById('own-share-expand');
1375  if(shareBtn)shareBtn.addEventListener('click',function(){openModalChart('Share of codebase (code lines) \u2014 Full View',760,shareConfig,'slices');});
1376})();
1377(function(){
1378  // Contributors table: click a header to sort asc/desc; drag the right edge to resize a column.
1379  var table=document.getElementById('own-contrib-table');if(!table)return;
1380  var thead=table.tHead;if(!thead)return;
1381  var ths=Array.prototype.slice.call(thead.rows[0].cells);
1382  var tbody=table.tBodies[0];if(!tbody)return;
1383  var sortCol=-1,sortDir=1;
1384  function cellVal(row,i,kind){var td=row.cells[i];if(!td)return kind==='num'?0:'';var t=td.textContent.trim();if(kind==='num'){var n=parseFloat(t.replace(/[^0-9.\-]/g,''));return isNaN(n)?-Infinity:n;}return t.toLowerCase();}
1385  function sortBy(i){
1386    var kind=ths[i].getAttribute('data-sort')||'text';
1387    if(sortCol===i){sortDir=-sortDir;}else{sortCol=i;sortDir=(kind==='num')?-1:1;}
1388    var rows=Array.prototype.slice.call(tbody.rows);
1389    rows.sort(function(a,b){var av=cellVal(a,i,kind),bv=cellVal(b,i,kind);if(av<bv)return -1*sortDir;if(av>bv)return 1*sortDir;return 0;});
1390    rows.forEach(function(r){tbody.appendChild(r);});
1391    ths.forEach(function(th,j){th.classList.toggle('own-sorted',j===i);var ind=th.querySelector('.own-sort-ind');if(ind)ind.textContent=j===i?(sortDir>0?'\u25B2':'\u25BC'):'';});
1392  }
1393  ths.forEach(function(th,i){
1394    var ind=document.createElement('span');ind.className='own-sort-ind';th.appendChild(ind);
1395    th.addEventListener('click',function(e){if(e.target.classList.contains('own-col-resizer'))return;sortBy(i);});
1396    var res=document.createElement('div');res.className='own-col-resizer';th.appendChild(res);
1397    var startX=0,startW=0;
1398    res.addEventListener('mousedown',function(e){e.preventDefault();e.stopPropagation();startX=e.pageX;startW=th.offsetWidth;document.body.classList.add('own-resizing');
1399      function mv(ev){var w=Math.max(48,startW+(ev.pageX-startX));th.style.width=w+'px';}
1400      function up(){document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);document.body.classList.remove('own-resizing');}
1401      document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);});
1402  });
1403})();
1404(function(){
1405  // Watched Folders: Choose opens the native directory picker, then adds it and reloads.
1406  var btn=document.getElementById('add-watched-btn');if(!btn)return;
1407  btn.addEventListener('click',function(){
1408    fetch('/pick-directory?kind=reports')
1409      .then(function(r){return r.ok?r.json():{cancelled:true};})
1410      .then(function(data){
1411        if(!data.cancelled&&data.selected_path){
1412          var form=document.createElement('form');form.method='POST';form.action='/watched-dirs/add';
1413          var ri=document.createElement('input');ri.type='hidden';ri.name='redirect_to';ri.value='/code-ownership';
1414          var fi=document.createElement('input');fi.type='hidden';fi.name='folder_path';fi.value=data.selected_path;
1415          form.appendChild(ri);form.appendChild(fi);document.body.appendChild(form);form.submit();
1416        }
1417      })
1418      .catch(function(e){alert('Could not open folder picker: '+e);});
1419  });
1420})();"#
1421}
1422
1423/// GET `/code-ownership` — per-author blame-based code ownership for the latest scan.
1424/// Path to the operator-defined author identity-merge map, alongside the scan registry.
1425fn identities_path(state: &AppState) -> PathBuf {
1426    state.registry_path.parent().map_or_else(
1427        || PathBuf::from("identities.json"),
1428        |p| p.join("identities.json"),
1429    )
1430}
1431
1432#[derive(Deserialize, Default)]
1433struct OwnershipQuery {
1434    /// Selected project label; empty/absent means "all projects" (the latest scan overall).
1435    project: Option<String>,
1436}
1437
1438async fn code_ownership_handler(
1439    State(state): State<AppState>,
1440    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1441    Query(query): Query<OwnershipQuery>,
1442) -> Response {
1443    let selected = query.project.as_deref().filter(|s| !s.is_empty());
1444
1445    // Pick up any new reports dropped into watched folders before rendering (mirrors the
1446    // View Reports / Test Metrics pages).
1447    auto_scan_watched_dirs(&state).await;
1448    let watched_dirs_list: Vec<String> = {
1449        let wd = state.watched_dirs.lock().await;
1450        wd.dirs.iter().map(|p| p.display().to_string()).collect()
1451    };
1452
1453    // Build the project picker list (one entry per distinct project label, newest scan first)
1454    // and resolve the JSON path of the scan to display: the selected project's latest scan, or
1455    // the latest scan overall when "all projects" is chosen.
1456    let (projects, json_path): (Vec<String>, Option<PathBuf>) = {
1457        let reg = state.registry.lock().await;
1458        let mut seen = std::collections::HashSet::new();
1459        let mut projects = Vec::new();
1460        for e in &reg.entries {
1461            if seen.insert(e.project_label.clone()) {
1462                projects.push(e.project_label.clone());
1463            }
1464        }
1465        let json_path = match selected {
1466            Some(label) => reg
1467                .entries
1468                .iter()
1469                .find(|e| e.project_label == label)
1470                .and_then(|e| e.json_path.clone()),
1471            None => reg.entries.first().and_then(|e| e.json_path.clone()),
1472        };
1473        (projects, json_path)
1474    };
1475
1476    let mut latest_run: Option<AnalysisRun> = if let Some(p) = json_path {
1477        tokio::fs::read_to_string(&p)
1478            .await
1479            .ok()
1480            .as_deref()
1481            .and_then(|s| serde_json::from_str(s).ok())
1482    } else {
1483        None
1484    };
1485
1486    // Apply any operator-defined identity merges on top of the scan's authors (post-hoc, no
1487    // re-scan): contributors whose emails were combined fold into one.
1488    let map = IdentityMap::load(&identities_path(&state));
1489    if let Some(run) = latest_run.as_mut() {
1490        auto_merge_noreply_identities(run);
1491        apply_identity_map(run, &map);
1492    }
1493
1494    let project_label = latest_run
1495        .as_ref()
1496        .and_then(|r| r.input_roots.first())
1497        .map(|p| {
1498            p.rsplit(['/', '\\'])
1499                .find(|s| !s.is_empty())
1500                .unwrap_or(p.as_str())
1501                .to_string()
1502        })
1503        .unwrap_or_else(|| "workspace".to_string());
1504
1505    let watched_bar = render_watched_bar(state.server_mode, &watched_dirs_list, "/code-ownership");
1506
1507    let html = render_code_ownership_html(
1508        &csp_nonce,
1509        latest_run.as_ref(),
1510        &project_label,
1511        &map.groups,
1512        &projects,
1513        selected,
1514        &watched_bar,
1515    );
1516    Html(html).into_response()
1517}
1518
1519/// Render the "Watched Folders" bar shared by the ownership page (mirrors View Reports / Test
1520/// Metrics). In Network Server mode the controls are replaced by a locked notice.
1521fn render_watched_bar(server_mode: bool, watched: &[String], redirect_to: &str) -> String {
1522    use std::fmt::Write as _;
1523    if server_mode {
1524        return 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 &mdash; watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string();
1525    }
1526    let chips: String = if watched.is_empty() {
1527        r#"<span class="watched-none">No folders watched &mdash; click Choose to add one</span>"#
1528            .to_string()
1529    } else {
1530        watched.iter().fold(String::new(), |mut s, d| {
1531            let esc = d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
1532            let _ = write!(
1533                s,
1534                r#"<span class="watched-chip"><span class="watched-chip-path" title="{esc}">{esc}</span><form method="POST" action="/watched-dirs/remove"><input type="hidden" name="folder_path" value="{esc}"><input type="hidden" name="redirect_to" value="{redirect}"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#,
1535                esc = esc,
1536                redirect = redirect_to,
1537            );
1538            s
1539        })
1540    };
1541    format!(
1542        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">{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"><input type="hidden" name="redirect_to" value="{redirect}"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#,
1543        chips = chips,
1544        redirect = redirect_to,
1545    )
1546}
1547
1548/// Parse an `application/x-www-form-urlencoded` body into ordered key/value pairs, preserving
1549/// repeated keys (which `serde_urlencoded`/`axum::Form` cannot deserialize into a `Vec`). Used
1550/// by the merge endpoint, whose checkbox group submits `email=` several times.
1551fn parse_form_pairs(body: &str) -> Vec<(String, String)> {
1552    body.split('&')
1553        .filter(|s| !s.is_empty())
1554        .map(|pair| {
1555            let mut it = pair.splitn(2, '=');
1556            let k = urldecode(it.next().unwrap_or(""));
1557            let v = urldecode(it.next().unwrap_or(""));
1558            (k, v)
1559        })
1560        .collect()
1561}
1562
1563/// Minimal `application/x-www-form-urlencoded` value decoder (`+` → space, `%XX` → byte).
1564fn urldecode(s: &str) -> String {
1565    let b = s.as_bytes();
1566    let mut out = Vec::with_capacity(b.len());
1567    let mut i = 0;
1568    while i < b.len() {
1569        match b[i] {
1570            b'+' => {
1571                out.push(b' ');
1572                i += 1;
1573            }
1574            b'%' if i + 3 <= b.len() => {
1575                match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or(""), 16) {
1576                    Ok(byte) => {
1577                        out.push(byte);
1578                        i += 3;
1579                    }
1580                    Err(_) => {
1581                        out.push(b'%');
1582                        i += 1;
1583                    }
1584                }
1585            }
1586            c => {
1587                out.push(c);
1588                i += 1;
1589            }
1590        }
1591    }
1592    String::from_utf8_lossy(&out).into_owned()
1593}
1594
1595/// POST `/api/ownership/merge` — combine the selected contributor emails into one identity.
1596/// The checkbox group submits `email=` repeatedly, so the body is parsed manually.
1597async fn ownership_merge_handler(State(state): State<AppState>, body: String) -> Response {
1598    let pairs = parse_form_pairs(&body);
1599    let emails: Vec<String> = pairs
1600        .iter()
1601        .filter(|(k, _)| k == "email")
1602        .map(|(_, v)| v.clone())
1603        .collect();
1604    let name = pairs
1605        .iter()
1606        .find(|(k, _)| k == "canonical_name")
1607        .map(|(_, v)| v.as_str());
1608    let path = identities_path(&state);
1609    let mut map = IdentityMap::load(&path);
1610    map.merge(&emails, name);
1611    let _ = map.save(&path);
1612    axum::response::Redirect::to(&merge_redirect_target(&pairs)).into_response()
1613}
1614
1615/// Resolve the post-merge redirect target from the form's optional `redirect_to` field. Defaults to
1616/// the dedicated Code Ownership page and only accepts same-origin absolute paths (must start with a
1617/// single `/`), so a crafted form can't bounce the operator to an external site.
1618fn merge_redirect_target(pairs: &[(String, String)]) -> String {
1619    pairs
1620        .iter()
1621        .find(|(k, _)| k == "redirect_to")
1622        .map(|(_, v)| v.as_str())
1623        .filter(|v| v.starts_with('/') && !v.starts_with("//"))
1624        .unwrap_or("/code-ownership")
1625        .to_string()
1626}
1627
1628/// POST `/api/ownership/unmerge` — split a previously merged identity back apart.
1629async fn ownership_unmerge_handler(State(state): State<AppState>, body: String) -> Response {
1630    let pairs = parse_form_pairs(&body);
1631    if let Some((_, email)) = pairs.iter().find(|(k, _)| k == "canonical_email") {
1632        let path = identities_path(&state);
1633        let mut map = IdentityMap::load(&path);
1634        map.unmerge(email);
1635        let _ = map.save(&path);
1636    }
1637    axum::response::Redirect::to(&merge_redirect_target(&pairs)).into_response()
1638}
1639
1640/// GET `/code-ownership/mailmap` — download the current merges as a git `.mailmap` file.
1641async fn ownership_mailmap_handler(State(state): State<AppState>) -> Response {
1642    let map = IdentityMap::load(&identities_path(&state));
1643    (
1644        [
1645            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
1646            (
1647                header::CONTENT_DISPOSITION,
1648                "attachment; filename=\".mailmap\"",
1649            ),
1650        ],
1651        map.to_mailmap(),
1652    )
1653        .into_response()
1654}
1655
1656/// Build the full Code Ownership HTML page. Kept self-contained: brace-heavy CSS and JS live in
1657/// raw-string values that are interpolated into the skeleton as opaque values (so their `{`/`}`
1658/// are never parsed as `format!` placeholders), while only the small dynamic content string is
1659/// built with `format!`.
1660#[allow(clippy::too_many_lines)]
1661fn render_code_ownership_html(
1662    nonce: &str,
1663    run: Option<&AnalysisRun>,
1664    project_label: &str,
1665    merge_groups: &[AuthorMergeGroup],
1666    projects: &[String],
1667    selected: Option<&str>,
1668    watched_bar: &str,
1669) -> String {
1670    let version = env!("CARGO_PKG_VERSION");
1671
1672    // ── Compute display data ──────────────────────────────────────────────────
1673    let authors = run.map(|r| r.authors.as_slice()).unwrap_or(&[]);
1674    let has_data = !authors.is_empty();
1675    let total_code: u64 = authors.iter().map(|a| a.counts.code_lines).sum();
1676
1677    // ── Main content ──────────────────────────────────────────────────────────
1678    let content = if !has_data {
1679        render_ownership_empty(project_label)
1680    } else {
1681        let rows = build_ownership_rows(run, total_code);
1682        let bus_factor = compute_bus_factor(authors, total_code);
1683        let lang_rows = build_language_rows(run, &rows);
1684        let data_json = ownership_data_json(&rows);
1685        render_ownership_populated(
1686            &rows,
1687            bus_factor,
1688            total_code,
1689            &lang_rows,
1690            &data_json,
1691            merge_groups,
1692            nonce,
1693            project_label,
1694        )
1695    };
1696
1697    let css = ownership_page_css();
1698    let nav = ownership_page_nav();
1699    let scripts = ownership_page_scripts();
1700    let project_selector = build_project_selector(projects, selected);
1701
1702    format!(
1703        r#"<!doctype html>
1704<html lang="en">
1705<head>
1706  <meta charset="utf-8" />
1707  <meta name="viewport" content="width=device-width, initial-scale=1" />
1708  <title>OxideSLOC | Code Ownership</title>
1709  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
1710  <link rel="stylesheet" href="/static/app.css">
1711  <script src="/static/app.js"></script>
1712  <script src="/static/chart.js"></script>
1713  <style nonce="{nonce}">{css}</style>
1714</head>
1715<body>
1716<div class="background-watermarks" aria-hidden="true">
1717  <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1718  <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1719  <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1720  <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1721  <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1722  <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1723</div>
1724<div class="code-particles" id="code-particles" aria-hidden="true"></div>
1725{nav}
1726<div class="page">
1727  {watched_bar}
1728  {project_selector}
1729  {content}
1730</div>
1731<footer class="site-footer">
1732  oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
1733  Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
1734  &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
1735  &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
1736  &nbsp;&middot;&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
1737</footer>
1738<script nonce="{nonce}">{scripts}</script>
1739</body>
1740</html>"#,
1741        nonce = nonce,
1742        css = css,
1743        nav = nav,
1744        watched_bar = watched_bar,
1745        project_selector = project_selector,
1746        content = content,
1747        version = version,
1748        scripts = scripts,
1749    )
1750}
1751
1752/// Build the project picker shown above the ownership content. Lets the user pin a specific
1753/// project's latest scan, or fall back to "All projects" (the most recent scan overall). Renders
1754/// nothing when the registry has no scans yet.
1755fn build_project_selector(projects: &[String], selected: Option<&str>) -> String {
1756    if projects.is_empty() {
1757        return String::new();
1758    }
1759    use std::fmt::Write as _;
1760    let mut opts = String::new();
1761    let all_sel = if selected.is_none() { " selected" } else { "" };
1762    let _ = write!(
1763        opts,
1764        r#"<option value=""{all_sel}>All projects (latest scan)</option>"#,
1765    );
1766    for p in projects {
1767        let sel = if selected == Some(p.as_str()) {
1768            " selected"
1769        } else {
1770            ""
1771        };
1772        let _ = write!(
1773            opts,
1774            r#"<option value="{v}"{sel}>{v}</option>"#,
1775            v = own_esc(p),
1776        );
1777    }
1778    format!(
1779        r#"<div class="own-project-bar">
1780  <label class="own-project-label" for="own-project-select">Project</label>
1781  <select id="own-project-select" class="own-project-select" aria-label="Select project">{opts}</select>
1782  <span class="own-project-hint">Pick a project to view its latest scan, or all projects.</span>
1783</div>"#,
1784    )
1785}
1786
1787/// Minimal HTML-escape for the dynamic text on the Code Ownership page.
1788fn own_esc(s: &str) -> String {
1789    s.replace('&', "&amp;")
1790        .replace('<', "&lt;")
1791        .replace('>', "&gt;")
1792        .replace('"', "&quot;")
1793}
1794
1795/// Ordered `(needles, abbreviation, brand-ish hex)` badge table. A needle is matched as a
1796/// lowercased substring, except one prefixed with `=`, which must match the whole (lowercased)
1797/// name exactly. Order matters: disambiguate C++/C#/Objective-C before the bare `=c` check, and
1798/// the exact single-letter needles (`=c`, `=d`, `=r`, `=go`, `=cpp`) sit after their substring
1799/// siblings so e.g. "cpp" never trips the bare `=c`.
1800static LANGUAGE_BADGES: &[(&[&str], &str, &str)] = &[
1801    (&["c++", "=cpp"], "C++", "#F34B7D"),
1802    (&["c#", "csharp", "c-sharp"], "C#", "#178600"),
1803    (&["objective"], "ObjC", "#438EFF"),
1804    (&["=c"], "C", "#555555"),
1805    (&["typescript"], "Ts", "#3178C6"),
1806    (&["javascript"], "Js", "#F1E05A"),
1807    (&["rust"], "Rs", "#DEA584"),
1808    (&["python"], "Py", "#3572A5"),
1809    (&["kotlin"], "Kt", "#A97BFF"),
1810    (&["java"], "Jv", "#B07219"),
1811    (&["golang", "=go"], "Go", "#00ADD8"),
1812    (&["ruby"], "Rb", "#701516"),
1813    (&["php"], "Php", "#4F5D95"),
1814    (&["swift"], "Sw", "#F05138"),
1815    (&["scala"], "Sc", "#C22D40"),
1816    (&["shell", "bash"], "Sh", "#89E051"),
1817    (&["powershell"], "Ps", "#012456"),
1818    (&["html"], "Ht", "#E34C26"),
1819    (&["scss", "sass"], "Sa", "#C6538C"),
1820    (&["css"], "Css", "#563D7C"),
1821    (&["haskell"], "Hs", "#5E5086"),
1822    (&["lua"], "Lua", "#000080"),
1823    (&["perl"], "Pl", "#0298C3"),
1824    (&["dart"], "Dt", "#00B4AB"),
1825    (&["elixir"], "Ex", "#6E4A7E"),
1826    (&["erlang"], "Er", "#B83998"),
1827    (&["clojure"], "Cl", "#DB5855"),
1828    (&["ocaml"], "Ml", "#EF7A08"),
1829    (&["f#", "fsharp"], "F#", "#B845FC"),
1830    (&["zig"], "Zig", "#EC915C"),
1831    (&["nim"], "Nim", "#FFC200"),
1832    (&["julia"], "Jl", "#A270BA"),
1833    (&["solidity"], "Sol", "#AA6746"),
1834    (&["sql"], "Sql", "#E38C00"),
1835    (&["docker"], "Dk", "#384D54"),
1836    (&["makefile"], "Mk", "#427819"),
1837    (&["cmake"], "Cm", "#DA3434"),
1838    (&["vue"], "Vue", "#41B883"),
1839    (&["svelte"], "Sv", "#FF3E00"),
1840    (&["assembly"], "Asm", "#6E4C13"),
1841    (&["fortran"], "Fo", "#4D41B1"),
1842    (&["ada"], "Ada", "#02A676"),
1843    (&["groovy"], "Gv", "#4298B8"),
1844    (&["graphql"], "Gql", "#E10098"),
1845    (&["protocol", "protobuf"], "Pb", "#4B7A9C"),
1846    (&["terraform", "hcl"], "Tf", "#844FBA"),
1847    (&["nix"], "Nix", "#7E7EFF"),
1848    (&["verilog"], "V", "#7A9FE8"),
1849    (&["vhdl"], "Vh", "#8892C8"),
1850    (&["visual basic", "vb"], "Vb", "#945DB7"),
1851    (&["pascal", "delphi"], "Pas", "#B0A030"),
1852    (&["crystal"], "Cr", "#333333"),
1853    (&["elm"], "Elm", "#60B5CC"),
1854    (&["tcl"], "Tcl", "#C9A227"),
1855    (&["awk"], "Awk", "#555555"),
1856    (&["glsl", "hlsl"], "Sl", "#5686A5"),
1857    (&["xml", "svg"], "Xml", "#0060AC"),
1858    (&["lisp", "scheme"], "Lsp", "#3FB68B"),
1859    (&["=d"], "D", "#BA595E"),
1860    (&["=r"], "R", "#198CE7"),
1861];
1862
1863/// Known `(abbreviation, brand-ish hex)` for a language display name. Matched loosely (lowercased,
1864/// substring) so it survives minor label variations; `None` falls back to initials + a neutral tone.
1865/// Deliberately generated inline SVG badges rather than downloaded icon files — keeps the tool fully
1866/// offline / air-gap friendly and sidesteps third-party icon licensing. See [`LANGUAGE_BADGES`] for
1867/// the ordered lookup table and needle-matching rules.
1868fn language_badge_meta(name: &str) -> Option<(&'static str, &'static str)> {
1869    let l = name.to_ascii_lowercase();
1870    LANGUAGE_BADGES.iter().find_map(|(needles, label, color)| {
1871        let hit = needles.iter().any(|n| match n.strip_prefix('=') {
1872            Some(exact) => l == exact,
1873            None => l.contains(n),
1874        });
1875        hit.then_some((*label, *color))
1876    })
1877}
1878
1879/// Render a small, uniform inline-SVG language badge (rounded square + abbreviation) for a language
1880/// display name. Used in the "Ownership by language" table and reusable anywhere languages are named.
1881fn language_badge(name: &str) -> String {
1882    let (abbr, color): (String, &str) = match language_badge_meta(name) {
1883        Some((a, c)) => (a.to_string(), c),
1884        None => {
1885            // Fallback keeps the same badge style with a neutral tone + the language's initials.
1886            let a: String = name
1887                .chars()
1888                .filter(char::is_ascii_alphanumeric)
1889                .take(2)
1890                .collect::<String>()
1891                .to_uppercase();
1892            (a, "#8a756a")
1893        }
1894    };
1895    // White text on dark badges, dark text on light badges (perceived luminance).
1896    let txt = {
1897        let h = color.trim_start_matches('#');
1898        let r = u32::from_str_radix(&h[0..2], 16).unwrap_or(120) as f64;
1899        let g = u32::from_str_radix(&h[2..4], 16).unwrap_or(120) as f64;
1900        let b = u32::from_str_radix(&h[4..6], 16).unwrap_or(120) as f64;
1901        if 0.299 * r + 0.587 * g + 0.114 * b > 150.0 {
1902            "#2a2018"
1903        } else {
1904            "#ffffff"
1905        }
1906    };
1907    let fs = if abbr.chars().count() >= 3 {
1908        "7"
1909    } else {
1910        "8.5"
1911    };
1912    format!(
1913        r#"<span class="lang-badge"><svg viewBox="0 0 20 20" width="22" height="22" aria-hidden="true"><rect x="0" y="0" width="20" height="20" rx="5" fill="{color}"></rect><text x="10" y="11" text-anchor="middle" font-family="Inter,ui-sans-serif,sans-serif" font-size="{fs}" font-weight="800" fill="{txt}">{abbr}</text></svg></span>"#,
1914        color = color,
1915        fs = fs,
1916        txt = txt,
1917        abbr = own_esc(&abbr),
1918    )
1919}
1920
1921/// Fold the run's per-file records into two per-author maps: `files_owned` (count of files where
1922/// the author is the single largest owner) and `test_counts` (`[code, comment, blank, total]` lines
1923/// owned in files classified as tests). Both empty when the run is `None`.
1924#[allow(clippy::type_complexity)] // two small, self-describing per-author maps
1925fn accumulate_ownership(
1926    run: Option<&AnalysisRun>,
1927) -> (
1928    std::collections::HashMap<u32, u64>,
1929    std::collections::HashMap<u32, [u64; 4]>,
1930) {
1931    let mut files_owned: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
1932    let mut test_counts: std::collections::HashMap<u32, [u64; 4]> =
1933        std::collections::HashMap::new();
1934    if let Some(r) = run {
1935        for rec in &r.per_file_records {
1936            if let Some(top) = rec.ownership.as_ref().and_then(|o| o.first()) {
1937                *files_owned.entry(top.author_id).or_default() += 1;
1938            }
1939            if rec.is_test_file()
1940                && let Some(own) = rec.ownership.as_ref()
1941            {
1942                for o in own {
1943                    let e = test_counts.entry(o.author_id).or_default();
1944                    e[0] += o.counts.code_lines;
1945                    e[1] += o.counts.comment_lines;
1946                    e[2] += o.counts.blank_lines;
1947                    e[3] += o.counts.total_lines;
1948                }
1949            }
1950        }
1951    }
1952    (files_owned, test_counts)
1953}
1954
1955/// Compute one display row per contributor. `files_owned` counts the files where each author is
1956/// the single largest owner (ownership lists are pre-sorted descending). Empty when the run has no
1957/// authors.
1958fn build_ownership_rows(run: Option<&AnalysisRun>, total_code: u64) -> Vec<OwnershipRow> {
1959    let authors = run.map(|r| r.authors.as_slice()).unwrap_or(&[]);
1960    let remote = run.and_then(|r| r.git_remote_url.as_deref());
1961    let (files_owned, test_counts) = accumulate_ownership(run);
1962    authors
1963        .iter()
1964        .enumerate()
1965        .map(|(i, a)| {
1966            let tc = test_counts.get(&a.id).copied().unwrap_or_default();
1967            OwnershipRow {
1968                name: a.canonical_name.clone(),
1969                email: a.canonical_email.clone(),
1970                profile: author_profile_url(remote, &a.canonical_name, &a.canonical_email),
1971                code: a.counts.code_lines,
1972                comment: a.counts.comment_lines,
1973                blank: a.counts.blank_lines,
1974                total: a.counts.total_lines,
1975                code_pct: if total_code > 0 {
1976                    a.counts.code_lines as f64 / total_code as f64 * 100.0
1977                } else {
1978                    0.0
1979                },
1980                files_owned: files_owned.get(&a.id).copied().unwrap_or(0),
1981                aliases: a.aliases.len(),
1982                color: OWNERSHIP_PALETTE[i % OWNERSHIP_PALETTE.len()],
1983                test_code: tc[0],
1984                test_comment: tc[1],
1985                test_blank: tc[2],
1986                test_total: tc[3],
1987            }
1988        })
1989        .collect()
1990}
1991
1992/// Bus factor: the fewest top contributors whose combined code covers ≥ 50% of the codebase.
1993fn compute_bus_factor(authors: &[Author], total_code: u64) -> usize {
1994    let mut acc = 0u64;
1995    let mut n = 0usize;
1996    for a in authors {
1997        acc += a.counts.code_lines;
1998        n += 1;
1999        if total_code > 0 && acc * 2 >= total_code {
2000            break;
2001        }
2002    }
2003    n
2004}
2005
2006/// Per-language ownership rows `(language, code_lines, top_owner_name, owner_pct)`, sorted by code
2007/// lines descending and capped at the top 15 languages.
2008fn build_language_rows(
2009    run: Option<&AnalysisRun>,
2010    rows: &[OwnershipRow],
2011) -> Vec<(String, u64, String, f64)> {
2012    let mut lang_map: std::collections::HashMap<String, std::collections::HashMap<u32, u64>> =
2013        std::collections::HashMap::new();
2014    if let Some(r) = run {
2015        for rec in &r.per_file_records {
2016            let (Some(lang), Some(ownership)) = (rec.language, rec.ownership.as_ref()) else {
2017                continue;
2018            };
2019            let entry = lang_map.entry(lang.display_name().to_string()).or_default();
2020            for own in ownership {
2021                *entry.entry(own.author_id).or_default() += own.counts.code_lines;
2022            }
2023        }
2024    }
2025    let author_name = |id: u32| -> String {
2026        rows.get(id as usize)
2027            .map(|r| r.name.clone())
2028            .unwrap_or_default()
2029    };
2030    let mut lang_rows: Vec<(String, u64, String, f64)> = lang_map
2031        .into_iter()
2032        .map(|(lang, by_author)| {
2033            let lang_total: u64 = by_author.values().sum();
2034            let (top_id, top_code) = by_author
2035                .iter()
2036                .max_by_key(|(_, c)| **c)
2037                .map(|(id, c)| (*id, *c))
2038                .unwrap_or((0, 0));
2039            let pct = if lang_total > 0 {
2040                top_code as f64 / lang_total as f64 * 100.0
2041            } else {
2042                0.0
2043            };
2044            (lang, lang_total, author_name(top_id), pct)
2045        })
2046        .collect();
2047    lang_rows.sort_by_key(|r| std::cmp::Reverse(r.1));
2048    lang_rows.truncate(15);
2049    lang_rows
2050}
2051
2052/// Serialize the per-contributor JSON data island consumed by the client-side bar re-render.
2053fn ownership_data_json(rows: &[OwnershipRow]) -> String {
2054    serde_json::to_string(
2055        &rows
2056            .iter()
2057            .map(|r| {
2058                serde_json::json!({
2059                    "name": r.name,
2060                    "email": r.email,
2061                    "code": r.code,
2062                    "comment": r.comment,
2063                    "blank": r.blank,
2064                    "total": r.total,
2065                    "color": r.color,
2066                    "profile": r.profile,
2067                    "test_code": r.test_code,
2068                    "test_comment": r.test_comment,
2069                    "test_blank": r.test_blank,
2070                    "test_total": r.test_total,
2071                })
2072            })
2073            .collect::<Vec<_>>(),
2074    )
2075    .unwrap_or_else(|_| "[]".to_string())
2076}
2077
2078/// The "no ownership data" placeholder shown when the latest scan produced no authors.
2079fn render_ownership_empty(project_label: &str) -> String {
2080    format!(
2081        r#"<div class="panel own-empty">
2082  <svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
2083  <div class="own-empty-title">No ownership data for the latest scan</div>
2084  <p class="muted sx-3ce1b575" >
2085    Code ownership is computed from <strong>git blame</strong> and is <strong>on by default</strong>.
2086    This scan has none &mdash; the scanned path is likely not a git repository, or attribution was
2087    turned off for this run. Re-scan a git repo with attribution on to populate this page:
2088  </p>
2089  <pre class="own-code">oxide-sloc analyze {project}</pre>
2090  <p class="muted sx-3ce1b575" >
2091    The scan root must be a git repository. Same-email identities are merged automatically;
2092    cross-account merges are a later step.
2093  </p>
2094</div>"#,
2095        project = own_esc(project_label),
2096    )
2097}
2098
2099/// Render the "Combine contributors" panel: a checkbox per contributor plus the list of any
2100/// active merges (each with an Unmerge form).
2101fn render_merge_panel(
2102    merge_groups: &[AuthorMergeGroup],
2103    rows: &[OwnershipRow],
2104    redirect_to: &str,
2105) -> String {
2106    use std::fmt::Write as _;
2107    let redirect = own_esc(redirect_to);
2108    let mut merge_checks = String::new();
2109    for r in rows {
2110        let _ = write!(
2111            merge_checks,
2112            r#"<label class="merge-opt"><input type="checkbox" name="email" value="{email}"><span class="merge-opt-dot" data-sx-style="background:{color};"></span><span class="merge-opt-name">{name}</span><span class="merge-opt-email">{email}</span></label>"#,
2113            email = own_esc(&r.email),
2114            color = r.color,
2115            name = own_esc(&r.name),
2116        );
2117    }
2118    // The .mailmap export is only meaningful once at least one merge exists, so both the download
2119    // link and the explanation of what a .mailmap is are shown only then.
2120    let (mailmap_link, mut merge_existing) = if merge_groups.is_empty() {
2121        (String::new(), String::new())
2122    } else {
2123        (
2124            r#"<a href="/code-ownership/mailmap" class="merge-mailmap-link" title="Download a git .mailmap file capturing the merges below">&#8681; Download .mailmap</a>"#
2125                .to_string(),
2126            r#"<div class="merge-existing"><div class="merge-existing-title">Active merges</div>"#
2127                .to_string(),
2128        )
2129    };
2130    if !merge_groups.is_empty() {
2131        for g in merge_groups {
2132            let members = g
2133                .members
2134                .iter()
2135                .map(|m| own_esc(m))
2136                .collect::<Vec<_>>()
2137                .join(", ");
2138            let _ = write!(
2139                merge_existing,
2140                r#"<div class="merge-chip"><span class="merge-chip-text"><strong>{name}</strong> &nbsp;{count} emails &middot; {members}</span><form class="sx-5add8e44" method="POST" action="/api/ownership/unmerge" ><input type="hidden" name="canonical_email" value="{cemail}"><input type="hidden" name="redirect_to" value="{redirect}"><button type="submit" class="merge-unmerge">Unmerge</button></form></div>"#,
2141                name = own_esc(&g.canonical_name),
2142                count = g.members.len(),
2143                members = members,
2144                cemail = own_esc(&g.canonical_email),
2145                redirect = redirect,
2146            );
2147        }
2148        // Explain what the .mailmap download actually does, right where it becomes actionable.
2149        merge_existing.push_str(
2150            r#"<p class="merge-mailmap-note"><strong>What is <code>.mailmap</code>?</strong> It is a small text file that git reads from your repository root. Downloading it and committing it as <code>.mailmap</code> makes git itself &mdash; and every future oxide-sloc scan &mdash; permanently treat the combined names and emails above as one contributor. That means <code>git shortlog</code>, <code>git blame</code>, and re-scans all show the merged identity automatically, so you never have to redo these merges. Without it, the merges above live only in this app.</p>"#,
2151        );
2152        merge_existing.push_str("</div>");
2153    }
2154    format!(
2155        r#"<div class="section-header">Combine contributors</div>
2156<div class="panel">
2157  <p class="muted sx-16dbf2a3" >Same person committing under different names or emails? Select two or more contributors and merge them into a single identity. This is applied on top of the scan &mdash; <strong>no re-scan needed</strong> &mdash; and can be exported as a git <code>.mailmap</code> so the merge carries into git itself and future scans.</p>
2158  <form method="POST" action="/api/ownership/merge">
2159    <input type="hidden" name="redirect_to" value="{redirect}">
2160    <div class="merge-grid">{merge_checks}</div>
2161    <div class="merge-controls">
2162      <input type="text" name="canonical_name" class="merge-name-input" placeholder="Canonical display name (optional)">
2163      <button type="submit" class="merge-btn">Merge selected</button>
2164      {mailmap_link}
2165    </div>
2166  </form>
2167  {merge_existing}
2168</div>
2169
2170"#,
2171        redirect = redirect,
2172        merge_checks = merge_checks,
2173        mailmap_link = mailmap_link,
2174        merge_existing = merge_existing,
2175    )
2176}
2177
2178/// Render the populated Code Ownership content: summary chips, the per-contributor bar chart,
2179/// the contributor + per-language tables, and the combine-contributors panel.
2180// Each argument is a distinct pre-computed view model; bundling them into a struct would just move
2181// the plumbing without improving clarity.
2182#[allow(clippy::too_many_arguments)]
2183fn render_ownership_populated(
2184    rows: &[OwnershipRow],
2185    bus_factor: usize,
2186    total_code: u64,
2187    lang_rows: &[(String, u64, String, f64)],
2188    data_json: &str,
2189    merge_groups: &[AuthorMergeGroup],
2190    nonce: &str,
2191    project_label: &str,
2192) -> String {
2193    use std::fmt::Write as _;
2194    // Render a contributor's name as a clickable profile link when a URL was resolved from the
2195    // repo remote, otherwise as plain escaped text. Used everywhere a name appears.
2196    let name_link = |r: &OwnershipRow| -> String {
2197        let name = own_esc(&r.name);
2198        match r.profile.as_deref() {
2199            Some(url) => format!(
2200                r#"<a class="own-profile-link" href="{url}" target="_blank" rel="noopener" title="View {name}'s profile / contributions">{name}</a>"#,
2201                url = own_esc(url),
2202                name = name,
2203            ),
2204            None => name,
2205        }
2206    };
2207    let top_owner = rows.first();
2208    let top_name = top_owner.map(&name_link).unwrap_or_default();
2209    let top_pct = top_owner.map(|r| r.code_pct).unwrap_or(0.0);
2210
2211    // Server-rendered bars (metric = code); JS re-renders on filter change and adds the
2212    // cursor-following tooltip.
2213    let max_code = rows.iter().map(|r| r.code).max().unwrap_or(1).max(1);
2214    let mut bars = String::new();
2215    for r in rows {
2216        let pct = (r.code as f64 / max_code as f64 * 100.0).round() as u64;
2217        let _ = write!(
2218            bars,
2219            r#"<div class="own-bar-row"><div class="own-bar-name" title="{email}">{name}</div><div class="own-bar-track"><div class="own-bar-fill" data-sx-style="width:{pct}%;background:{color};"></div></div><div class="own-bar-val">{val}</div></div>"#,
2220            email = own_esc(&r.email),
2221            name = name_link(r),
2222            pct = pct,
2223            color = r.color,
2224            val = fmt_num(r.code as i64),
2225        );
2226    }
2227
2228    let mut author_table = String::new();
2229    for r in rows {
2230        let dev_code = r.code.saturating_sub(r.test_code);
2231        let _ = write!(
2232            author_table,
2233            r#"<tr><td><span class="own-dot" data-sx-style="background:{color};"></span>{name}</td><td><button type="button" class="own-email-copy" data-email="{email}" title="Click to copy this email">{email}</button></td><td class="num">{code}</td><td class="num">{dev_code}</td><td class="num">{test_code}</td><td class="num">{comment}</td><td class="num">{blank}</td><td class="num">{total}</td><td class="num">{pct:.1}%</td><td class="num">{files}</td><td class="num">{aliases}</td></tr>"#,
2234            color = r.color,
2235            name = name_link(r),
2236            email = own_esc(&r.email),
2237            code = fmt_num(r.code as i64),
2238            dev_code = fmt_num(dev_code as i64),
2239            test_code = fmt_num(r.test_code as i64),
2240            comment = fmt_num(r.comment as i64),
2241            blank = fmt_num(r.blank as i64),
2242            total = fmt_num(r.total as i64),
2243            pct = r.code_pct,
2244            files = r.files_owned,
2245            aliases = r.aliases,
2246        );
2247    }
2248
2249    let mut lang_table = String::new();
2250    for (lang, code, owner, pct) in lang_rows {
2251        let _ = write!(
2252            lang_table,
2253            r#"<tr><td><span class="lang-cell">{badge}<span class="lang-cell-name">{lang}</span></span></td><td class="num">{code}</td><td>{owner}</td><td class="num">{pct:.1}%</td></tr>"#,
2254            badge = language_badge(lang),
2255            lang = own_esc(lang),
2256            code = fmt_num(*code as i64),
2257            owner = own_esc(owner),
2258            pct = pct,
2259        );
2260    }
2261
2262    let merge_panel = render_merge_panel(merge_groups, rows, "/code-ownership");
2263
2264    // Derived roll-ups for the extra summary chips.
2265    let total_comment: u64 = rows.iter().map(|r| r.comment).sum();
2266    let total_test_code: u64 = rows.iter().map(|r| r.test_code).sum();
2267    let total_dev_code = total_code.saturating_sub(total_test_code);
2268    let test_pct = if total_code > 0 {
2269        total_test_code as f64 / total_code as f64 * 100.0
2270    } else {
2271        0.0
2272    };
2273    // Page intro, moved here (below the summary cards, above the charts) so the page top matches
2274    // the other surfaces (no big page title).
2275    let intro = format!(
2276        r#"<p class="muted own-page-intro">Per-author line ownership for <strong>{project}</strong>, derived from git blame. Filter the chart by line category; the table breaks down code, comments, and blanks per contributor.</p>"#,
2277        project = own_esc(project_label),
2278    );
2279
2280    format!(
2281        r#"<div class="summary-strip">
2282  <div class="stat-chip"><div class="stat-chip-val">{contributors}</div><div class="stat-chip-label">Contributors</div><span class="stat-chip-tip">Distinct authors who own at least one line in this scan, after identity merges are applied.</span></div>
2283  <div class="stat-chip"><div class="stat-chip-val">{top_name}</div><div class="stat-chip-label">Top Owner &middot; {top_pct:.0}% of code</div><span class="stat-chip-tip">The single contributor who owns the largest share of code lines, and what percentage of all code that is. Click the name to open their profile.</span></div>
2284  <div class="stat-chip"><div class="stat-chip-val">{bus_factor}</div><div class="stat-chip-label">Bus Factor (owners of 50% code)</div><span class="stat-chip-tip">The fewest contributors who together own at least half of the code. A low number means knowledge is concentrated in very few people &mdash; a project risk.</span></div>
2285  <div class="stat-chip"><div class="stat-chip-val">{total_code}</div><div class="stat-chip-label">Total Code Lines</div><span class="stat-chip-tip">Total physical code lines attributed across all contributors. Comments and blank lines are excluded from this figure.</span></div>
2286  <div class="stat-chip"><div class="stat-chip-val">{dev_code}</div><div class="stat-chip-label">Development Code</div><span class="stat-chip-tip">Code lines owned in non-test files. This is total code minus code that lives in files detected as tests.</span></div>
2287  <div class="stat-chip"><div class="stat-chip-val">{test_code}</div><div class="stat-chip-label">Test Code &middot; {test_pct:.0}% of code</div><span class="stat-chip-tip">Code lines owned in files classified as tests (detected from test functions/assertions or a test-path convention). A healthy share suggests good test coverage effort.</span></div>
2288  <div class="stat-chip"><div class="stat-chip-val">{total_comment}</div><div class="stat-chip-label">Total Comment Lines</div><span class="stat-chip-tip">Physical comment / documentation lines attributed across all contributors.</span></div>
2289</div>
2290
2291{intro}
2292
2293<div class="section-header">Contributor breakdown</div>
2294<p class="muted own-charts-sub">Interactive charts for this scan &mdash; hover a legend entry to isolate it, click a bar or slice to open that contributor's profile, and use <strong>Full View</strong> for a larger chart. These follow the ownership scope filter below.</p>
2295<div class="own-charts-grid">
2296  <div class="chart-box">
2297    <div class="toolbar"><div class="toolbar-left"><span class="chart-box-title">Line composition per contributor</span></div><button type="button" class="chart-expand-btn" id="own-comp-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button></div>
2298    <div class="own-canvas-wrap" id="own-comp-wrap"><canvas id="own-canvas-composition"></canvas></div>
2299    <div class="own-legend" id="own-comp-legend"></div>
2300    <div class="own-canvas-note" id="own-comp-note" hidden></div>
2301    <div class="own-canvas-empty" id="own-comp-empty" hidden>No contributor data to chart.</div>
2302  </div>
2303  <div class="chart-box">
2304    <div class="toolbar"><div class="toolbar-left"><span class="chart-box-title">Share of codebase (code lines)</span></div><button type="button" class="chart-expand-btn" id="own-share-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button></div>
2305    <div class="own-canvas-wrap own-canvas-wrap-donut" id="own-share-wrap"><canvas id="own-canvas-share"></canvas></div>
2306    <div class="own-legend" id="own-share-legend"></div>
2307    <div class="own-canvas-note" id="own-share-note" hidden></div>
2308    <div class="own-canvas-empty" id="own-share-empty" hidden>No contributor data to chart.</div>
2309  </div>
2310</div>
2311
2312<div class="chart-box">
2313  <div class="own-chart-head">
2314    <div class="chart-box-title">Lines owned per contributor</div>
2315    <div class="own-control-groups">
2316      <div class="own-controls" id="own-scope-controls" role="group" aria-label="Ownership scope">
2317        <button type="button" class="own-scope active" data-scope="all">All files</button>
2318        <button type="button" class="own-scope" data-scope="dev">Development</button>
2319        <button type="button" class="own-scope" data-scope="test">Tests</button>
2320      </div>
2321      <div class="own-controls" id="own-metric-controls" role="group" aria-label="Line category">
2322        <button type="button" class="own-filter active" data-metric="code">Code</button>
2323        <button type="button" class="own-filter" data-metric="comment">Comment</button>
2324        <button type="button" class="own-filter" data-metric="blank">Blank</button>
2325        <button type="button" class="own-filter" data-metric="total">Total</button>
2326      </div>
2327    </div>
2328  </div>
2329  <div id="own-bars">{bars}</div>
2330</div>
2331
2332<div class="section-header">Contributors</div>
2333<p class="own-table-hint">Click a contributor's <strong>name</strong> to open their profile / contributions on the hosting platform; click an <strong>email</strong> to copy it. <em>Dev</em> and <em>Test</em> split code lines by whether the owning file is a test.</p>
2334<div class="panel sx-e6a29e9c" >
2335  <div class="own-table-scroll">
2336  <table class="data-table own-contrib-table" id="own-contrib-table">
2337    <thead><tr><th data-sort="text">Author</th><th data-sort="text">Email</th><th class="num" data-sort="num">Code</th><th class="num" data-sort="num">Dev Code</th><th class="num" data-sort="num">Test Code</th><th class="num" data-sort="num">Comment</th><th class="num" data-sort="num">Blank</th><th class="num" data-sort="num">Total</th><th class="num" data-sort="num">Code %</th><th class="num" data-sort="num">Files Owned</th><th class="num" data-sort="num">Aliases</th></tr></thead>
2338    <tbody>{author_table}</tbody>
2339  </table>
2340  </div>
2341</div>
2342
2343<div class="section-header">Ownership by language</div>
2344<div class="panel sx-e6a29e9c" >
2345  <table class="data-table">
2346    <thead><tr><th>Language</th><th class="num">Code Lines</th><th>Top Owner</th><th class="num">Owner %</th></tr></thead>
2347    <tbody>{lang_table}</tbody>
2348  </table>
2349</div>
2350
2351{merge_panel}
2352<div class="panel own-footnote">
2353  <p class="muted sx-8d842990" >Ownership reflects the author who last touched each physical line (<code>git blame -w -M -C</code>, <code>.mailmap</code> honoured). Counts are physical lines and sum to the file's line total; they can differ slightly from the policy-adjusted SLOC totals elsewhere. Same-email identities are auto-merged; use <strong>Combine contributors</strong> above to merge across different emails. Test vs. development lines are split by classifying each <em>file</em> as a test (via detected test functions/assertions or a test-path convention), then attributing that file's owned lines accordingly.</p>
2354</div>
2355
2356<div class="own-copy-toast" id="own-copy-toast" role="status" aria-live="polite" hidden>Email copied</div>
2357<script id="own-data" type="application/json" nonce="{nonce}">{data_json}</script>"#,
2358        contributors = rows.len(),
2359        intro = intro,
2360        top_name = top_name,
2361        top_pct = top_pct,
2362        bus_factor = bus_factor,
2363        total_code = fmt_num(total_code as i64),
2364        dev_code = fmt_num(total_dev_code as i64),
2365        test_code = fmt_num(total_test_code as i64),
2366        test_pct = test_pct,
2367        total_comment = fmt_num(total_comment as i64),
2368        bars = bars,
2369        author_table = author_table,
2370        lang_table = lang_table,
2371        merge_panel = merge_panel,
2372        nonce = nonce,
2373        data_json = data_json,
2374    )
2375}
2376
2377#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
2378fn build_router(state: AppState) -> Router {
2379    let protected = Router::new()
2380        .route("/", get(splash))
2381        .route("/scan-setup", get(scan_setup_handler))
2382        .route("/scan", get(index))
2383        .route("/analyze", post(analyze_handler))
2384        .route("/preview", get(preview_handler))
2385        .route("/api/suggest-coverage", get(api_suggest_coverage))
2386        .route("/api/attribution-estimate", get(api_attribution_estimate))
2387        .route("/pick-directory", get(pick_directory_handler))
2388        .route("/open-path", get(open_path_handler))
2389        .route("/pick-file", get(pick_file_handler))
2390        .route(
2391            "/api/upload-directory",
2392            post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
2393        )
2394        .route(
2395            "/api/upload-file",
2396            post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
2397        )
2398        .route(
2399            "/api/upload-tarball",
2400            // Limit to SLOC_MAX_TARBALL_MB (default 2 048 MB) at the HTTP layer.
2401            // The handler also enforces this limit during streaming so both layers agree.
2402            post(upload_tarball_handler)
2403                .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
2404        )
2405        .route("/locate-report", post(locate_report_handler))
2406        .route("/locate-reports-dir", post(locate_reports_dir_handler))
2407        .route("/relocate-scan", post(relocate_scan_handler))
2408        .route("/watched-dirs/add", post(add_watched_dir_handler))
2409        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
2410        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
2411        .route("/view-reports", get(history_handler))
2412        .route("/compare-scans", get(compare_select_handler))
2413        .route("/compare", get(compare_handler))
2414        .route("/multi-compare", get(multi_compare_handler))
2415        .route("/images/{folder}/{file}", get(image_handler))
2416        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
2417        .route("/api/metrics/latest", get(api_metrics_latest_handler))
2418        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
2419        .route("/api/metrics/history", get(api_metrics_history_handler))
2420        .route("/api/metrics/churn", get(api_metrics_churn_handler))
2421        .route(
2422            "/api/metrics/submodules",
2423            get(api_metrics_submodules_handler),
2424        )
2425        .route("/api/ingest", post(api_ingest_handler))
2426        .route("/api/project-history", get(project_history_handler))
2427        .route("/trend-reports", get(trend_report_handler))
2428        .route("/test-metrics", get(test_metrics_handler))
2429        .route("/code-ownership", get(code_ownership_handler))
2430        .route("/code-ownership/mailmap", get(ownership_mailmap_handler))
2431        .route("/api/ownership/merge", post(ownership_merge_handler))
2432        .route("/api/ownership/unmerge", post(ownership_unmerge_handler))
2433        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
2434        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
2435        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
2436        .route("/runs/result/{run_id}", get(async_run_result_handler))
2437        .route("/embed/summary", get(embed_handler))
2438        // ── Git browser ────────────────────────────────────────────────────────
2439        .route("/git-browser", get(git_browser::git_browser_handler))
2440        .route("/api/git/refs", get(git_browser::api_list_refs))
2441        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
2442        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
2443        // ── Report export (HTML→PDF via headless Chrome) ──────────────────────
2444        // The request body is the full rendered HTML report, whose size scales
2445        // with file count — large repos (Compare Scans, Files, Trend, Test
2446        // Metrics) can exceed the global 10 MB limit and 413 without this raise.
2447        .route(
2448            "/export/pdf",
2449            post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
2450        )
2451        // ── Config export / import ─────────────────────────────────────────────
2452        .route("/export-config", get(export_config_handler))
2453        .route("/import-config", post(import_config_handler))
2454        // ── Scan profiles ──────────────────────────────────────────────────────
2455        .route("/api/scan-profiles", get(api_list_scan_profiles))
2456        .route("/api/scan-profiles", post(api_save_scan_profile))
2457        .route(
2458            "/api/scan-profiles/{id}",
2459            axum::routing::delete(api_delete_scan_profile),
2460        )
2461        // ── Report a Bug page ─────────────────────────────────────────────────
2462        .route("/report-bug", get(report_bug::report_bug_handler))
2463        // ── Integrations (webhooks + Confluence) ──────────────────────────────
2464        .route("/integrations", get(integrations::integrations_handler))
2465        .route(
2466            "/webhook-setup",
2467            get(|| async { axum::response::Redirect::permanent("/integrations") }),
2468        )
2469        .route(
2470            "/confluence-setup",
2471            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
2472        )
2473        .route("/api/schedules", get(git_webhook::api_list_schedules))
2474        .route("/api/schedules", post(git_webhook::api_create_schedule))
2475        .route(
2476            "/api/schedules",
2477            axum::routing::delete(git_webhook::api_delete_schedule),
2478        )
2479        .route(
2480            "/api/confluence/config",
2481            get(confluence::api_get_confluence_config),
2482        )
2483        .route(
2484            "/api/confluence/config",
2485            post(confluence::api_save_confluence_config),
2486        )
2487        .route(
2488            "/api/confluence/test",
2489            post(confluence::api_test_confluence),
2490        )
2491        .route(
2492            "/api/confluence/post",
2493            post(confluence::api_post_to_confluence),
2494        )
2495        .route(
2496            "/api/confluence/wiki-markup",
2497            get(confluence::api_wiki_markup),
2498        )
2499        // ── Run lifecycle: bundle download + delete + cleanup ─────────────────
2500        .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
2501        .route("/api/runs/{run_id}/export", post(export_run_handler))
2502        .route(
2503            "/api/runs/{run_id}",
2504            axum::routing::delete(delete_run_handler),
2505        )
2506        .route("/api/runs/cleanup", post(cleanup_runs_handler))
2507        // ── Auto-cleanup policy ────────────────────────────────────────────────
2508        .route(
2509            "/api/cleanup-policy",
2510            get(api_get_cleanup_policy)
2511                .post(api_save_cleanup_policy)
2512                .delete(api_delete_cleanup_policy),
2513        )
2514        .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
2515        // ── Operator-only effective-config view ────────────────────────────────
2516        .route("/api/admin/config", get(api_admin_config))
2517        // ── REST API reference page ────────────────────────────────────────────
2518        .route("/api-docs", get(api_docs_handler))
2519        // ── Prometheus metrics — behind API-key auth ───────────────────────────
2520        .route("/metrics", get(metrics_handler))
2521        .route_layer(middleware::from_fn_with_state(
2522            state.clone(),
2523            auth::require_api_key,
2524        ));
2525
2526    protected
2527        .route("/healthz", get(healthz))
2528        .route("/readyz", get(readyz))
2529        .route("/api/health", get(api_health_handler))
2530        .route("/api/version", get(api_version_handler))
2531        // Air-gap posture for the shared footer script — public so it works on every page,
2532        // including authenticated servers. Non-sensitive (offline flag + repo URL).
2533        .route("/api/connectivity", get(connectivity::connectivity_handler))
2534        .route("/api/openapi.yaml", get(openapi_yaml_handler))
2535        .route("/llms.txt", get(llms_txt_handler))
2536        .route("/llms-full.txt", get(llms_full_txt_handler))
2537        .route("/badge/{metric}", get(badge_handler))
2538        .route("/static/chart.js", get(chart_js_handler))
2539        .route("/static/chart-report.js", get(report_chart_js_handler))
2540        .route("/static/app.css", get(app_css_handler))
2541        .route("/static/app.js", get(app_js_handler))
2542        .route("/auth/login", get(auth::auth_login_get))
2543        .route("/auth/login", post(auth::auth_login_post))
2544        .route("/auth/logout", post(auth::auth_logout))
2545        // Pre-access consent acknowledgement endpoint (public; exempt from the gate).
2546        .route("/auth/consent", get(auth::auth_consent_accept))
2547        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
2548        // Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
2549        .route(
2550            "/webhooks/github",
2551            post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2552        )
2553        .route(
2554            "/webhooks/gitlab",
2555            post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2556        )
2557        .route(
2558            "/webhooks/bitbucket",
2559            post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2560        )
2561        // Provider-agnostic build-completion trigger: any upstream CI build (even
2562        // a legacy pipeline) can post a small signed JSON body to launch a scan.
2563        .route(
2564            "/webhooks/ci",
2565            post(git_webhook::handle_ci_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2566        )
2567        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
2568        .layer(middleware::from_fn(consent_gate))
2569        .layer(middleware::from_fn(csrf_protect))
2570        .layer(middleware::from_fn_with_state(
2571            state.clone(),
2572            host_allowlist_guard,
2573        ))
2574        .layer(middleware::from_fn_with_state(
2575            state.clone(),
2576            add_security_headers,
2577        ))
2578        .layer(build_cors_layer(state.server_mode))
2579        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
2580        // Transparently gzip large text/JSON responses when the client accepts it.
2581        .layer(middleware::from_fn(compress_response))
2582        // Outermost: bound total request time as a safety net against hung/slow
2583        // connections. Generous by default so real scans/PDF exports aren't cut off.
2584        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
2585            axum::http::StatusCode::REQUEST_TIMEOUT,
2586            http_timeout(),
2587        ))
2588        .with_state(state)
2589}
2590
2591/// Whole-request timeout applied as the outermost layer. A generous safety net
2592/// against hung or slow-loris connections that does not cut off legitimate long
2593/// operations (large-repo scans, PDF export). Override with
2594/// `SLOC_HTTP_TIMEOUT_SECS`; `0` effectively disables it (24h ceiling).
2595fn http_timeout() -> std::time::Duration {
2596    let secs = std::env::var("SLOC_HTTP_TIMEOUT_SECS")
2597        .ok()
2598        .and_then(|s| s.trim().parse::<u64>().ok())
2599        .unwrap_or(600);
2600    std::time::Duration::from_secs(if secs == 0 { 86_400 } else { secs })
2601}
2602
2603// ── Response compression (hand-rolled gzip via flate2) ─────────────────────────
2604// A dependency-free alternative to tower-http's CompressionLayer (whose
2605// async-compression crate is not in the offline vendor tree). Buffers and gzips
2606// only text-like responses of a worthwhile, known size; streaming, already-encoded,
2607// or binary/precompressed responses pass through untouched.
2608
2609/// Don't bother compressing tiny bodies (header overhead outweighs the win).
2610const COMPRESS_MIN_BYTES: u64 = 1024;
2611/// Never buffer a body larger than this to compress it (memory safety cap).
2612const COMPRESS_MAX_BYTES: u64 = 32 * 1024 * 1024;
2613
2614/// True when the client's `Accept-Encoding` lists gzip.
2615fn client_accepts_gzip(headers: &axum::http::HeaderMap) -> bool {
2616    headers
2617        .get(header::ACCEPT_ENCODING)
2618        .and_then(|v| v.to_str().ok())
2619        .is_some_and(|val| {
2620            val.split(',').any(|enc| {
2621                enc.split(';')
2622                    .next()
2623                    .unwrap_or("")
2624                    .trim()
2625                    .eq_ignore_ascii_case("gzip")
2626            })
2627        })
2628}
2629
2630/// Compress text-like payloads only; binary/precompressed types (pdf, gzip, zip,
2631/// images, octet-stream) gain nothing and are skipped.
2632fn is_compressible_type(content_type: &str) -> bool {
2633    let ct = content_type
2634        .split(';')
2635        .next()
2636        .unwrap_or("")
2637        .trim()
2638        .to_ascii_lowercase();
2639    ct.starts_with("text/")
2640        || matches!(
2641            ct.as_str(),
2642            "application/json"
2643                | "application/javascript"
2644                | "application/xml"
2645                | "application/yaml"
2646                | "application/manifest+json"
2647                | "image/svg+xml"
2648        )
2649}
2650
2651/// Middleware: transparently gzip eligible responses when the client accepts it.
2652async fn compress_response(req: Request<Body>, next: Next) -> Response {
2653    let accepts_gzip = client_accepts_gzip(req.headers());
2654    let resp = next.run(req).await;
2655    // Skip when the client can't take gzip or the response is already encoded.
2656    if !accepts_gzip || resp.headers().contains_key(header::CONTENT_ENCODING) {
2657        return resp;
2658    }
2659    let content_type = resp
2660        .headers()
2661        .get(header::CONTENT_TYPE)
2662        .and_then(|v| v.to_str().ok())
2663        .unwrap_or("")
2664        .to_owned();
2665    if !is_compressible_type(&content_type) {
2666        return resp;
2667    }
2668
2669    let (mut parts, body) = resp.into_parts();
2670    // Only compress bodies whose exact size is known and worthwhile; pass
2671    // streaming/unknown or out-of-band sizes through without buffering.
2672    let eligible = matches!(
2673        http_body::Body::size_hint(&body).exact(),
2674        Some(n) if (COMPRESS_MIN_BYTES..=COMPRESS_MAX_BYTES).contains(&n)
2675    );
2676    if !eligible {
2677        return Response::from_parts(parts, body);
2678    }
2679
2680    let bytes = match axum::body::to_bytes(body, COMPRESS_MAX_BYTES as usize).await {
2681        Ok(b) => b,
2682        // Guarded against by the size check above; degrade gracefully if hit.
2683        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2684    };
2685
2686    use std::io::Write as _;
2687    let mut encoder = flate2::write::GzEncoder::new(
2688        Vec::with_capacity(bytes.len() / 2),
2689        flate2::Compression::default(),
2690    );
2691    if encoder.write_all(&bytes).is_err() {
2692        return Response::from_parts(parts, Body::from(bytes));
2693    }
2694    let compressed = match encoder.finish() {
2695        Ok(c) => c,
2696        Err(_) => return Response::from_parts(parts, Body::from(bytes)),
2697    };
2698
2699    parts.headers.remove(header::CONTENT_LENGTH);
2700    parts
2701        .headers
2702        .insert(header::CONTENT_LENGTH, HeaderValue::from(compressed.len()));
2703    parts
2704        .headers
2705        .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
2706    parts
2707        .headers
2708        .append(header::VARY, HeaderValue::from_static("accept-encoding"));
2709    Response::from_parts(parts, Body::from(compressed))
2710}
2711
2712/// Bearer token used by `make_test_router_server_mode()` test routers.
2713/// Tests that exercise server-mode paths must include this key in their requests.
2714pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
2715
2716/// Default `AppState` for integration tests: no API keys, no TLS, single-tenant local mode,
2717/// with all on-disk stores rooted under a per-test temp subdirectory. Individual test-router
2718/// builders below start from this and override only the fields they care about.
2719///
2720/// Always suppresses native OS dialogs (file pickers, open-path) via `SLOC_HEADLESS`.
2721fn test_app_state(tmp_subdir: &str) -> AppState {
2722    // Root every router in its OWN temp subdirectory. Multiple routers share a
2723    // namespace prefix (e.g. "sloc_test"), so a fixed name would make parallel
2724    // tests read/write the same registry.json + artifact tree and race — a
2725    // concurrently-mutated shared store is what made multi_compare_* flaky.
2726    // A per-call counter (plus PID, to avoid leftover-dir collisions across
2727    // runs) guarantees isolation, honouring this fn's "per-test subdir" contract.
2728    static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2729    // FIXME: Audit that the environment access only happens in single-threaded code.
2730    unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
2731    let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2732    let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
2733    AppState {
2734        base_config: AppConfig::default(),
2735        artifacts: Arc::new(Mutex::new(HashMap::new())),
2736        async_runs: Arc::new(Mutex::new(HashMap::new())),
2737        registry: Arc::new(Mutex::new(ScanRegistry::default())),
2738        registry_path: tmp.join("registry.json"),
2739        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
2740        server_mode: false,
2741        allow_unauthenticated: false,
2742        tls_enabled: false,
2743        api_keys: Arc::new(vec![]),
2744        readonly_api_keys: Arc::new(vec![]),
2745        rate_limiter: Arc::new(IpRateLimiter::new(
2746            Duration::from_mins(1),
2747            600,
2748            10,
2749            Duration::from_hours(1),
2750        )),
2751        trust_proxy: false,
2752        trusted_proxy_ips: vec![],
2753        git_clones_dir: tmp.join("git-clones"),
2754        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
2755        schedules_path: tmp.join("schedules.json"),
2756        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
2757        scan_profiles_path: tmp.join("scan_profiles.json"),
2758        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
2759        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
2760        confluence_path: tmp.join("confluence_config.json"),
2761        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
2762        watched_dirs_path: tmp.join("watched_dirs.json"),
2763        cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
2764        cleanup_policy_path: tmp.join("cleanup_policy.json"),
2765        cleanup_task_handle: Arc::new(Mutex::new(None)),
2766    }
2767}
2768
2769/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
2770pub fn make_test_router() -> Router {
2771    build_router(test_app_state("sloc_test"))
2772}
2773
2774/// Test router with one API key pre-loaded. Used by auth integration tests.
2775pub fn make_test_router_with_key(api_key: &str) -> Router {
2776    let mut state = test_app_state("sloc_test_key");
2777    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
2778    build_router(state)
2779}
2780
2781/// Test router with a full-access key AND a read-only key.
2782///
2783/// Exercises the read-only credential branch in the auth middleware: a read-only
2784/// key authenticates safe (GET/HEAD/OPTIONS) requests but is rejected with 403 on
2785/// state-changing methods.
2786pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
2787    let mut state = test_app_state("sloc_test_readonly");
2788    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
2789    state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
2790        readonly_key.to_owned(),
2791    ))]);
2792    build_router(state)
2793}
2794
2795/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
2796/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
2797/// preview restrictions.
2798pub fn make_test_router_server_mode() -> Router {
2799    let mut state = test_app_state("sloc_test_server");
2800    state.server_mode = true;
2801    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
2802        TEST_SERVER_MODE_API_KEY.to_owned(),
2803    ))]);
2804    build_router(state)
2805}
2806
2807/// Server-mode test router with `allowed_scan_roots` configured.
2808///
2809/// Exercises the `validate_server_scan_path` allow/deny branches (in-root
2810/// success, unresolved path, and out-of-root rejection) that the empty-roots
2811/// router cannot reach.
2812pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
2813    let mut state = test_app_state("sloc_test_server_roots");
2814    state.server_mode = true;
2815    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
2816        TEST_SERVER_MODE_API_KEY.to_owned(),
2817    ))]);
2818    state.base_config.discovery.allowed_scan_roots = roots;
2819    build_router(state)
2820}
2821
2822/// Test router where the analysis semaphore is pre-exhausted (0 permits).
2823/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
2824pub fn make_test_router_exhausted_semaphore() -> Router {
2825    let mut state = test_app_state("sloc_test_exhaust");
2826    state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
2827    build_router(state)
2828}
2829
2830/// Test router with a very tight rate limit (3 req/min). The third request from
2831/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
2832pub fn make_test_router_tight_rate_limit() -> Router {
2833    let mut state = test_app_state("sloc_test_rate");
2834    state.rate_limiter = Arc::new(IpRateLimiter::new(
2835        Duration::from_mins(1),
2836        2,
2837        5,
2838        Duration::from_secs(5),
2839    ));
2840    build_router(state)
2841}
2842
2843/// Test router with a very tight auth lockout (threshold=2, window=200ms).
2844/// Used by tests that need to trigger and verify the auth lockout response.
2845pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
2846    let mut state = test_app_state("sloc_test_auth_lockout");
2847    state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
2848    state.rate_limiter = Arc::new(IpRateLimiter::new(
2849        Duration::from_mins(1),
2850        600,
2851        2,                          // 2 failures triggers lockout
2852        Duration::from_millis(200), // 200ms lockout window (expires fast in tests)
2853    ));
2854    build_router(state)
2855}
2856
2857struct RuntimeSecurityConfig {
2858    api_keys: Vec<secrecy::SecretBox<String>>,
2859    readonly_api_keys: Vec<secrecy::SecretBox<String>>,
2860    tls_cert: Option<String>,
2861    tls_key: Option<String>,
2862    tls_enabled: bool,
2863    trust_proxy: bool,
2864    trusted_proxy_ips: Vec<IpAddr>,
2865    rate_limiter: Arc<IpRateLimiter>,
2866}
2867
2868/// Whether the operator has explicitly opted into running server mode with no API key.
2869/// This is the single escape hatch for the fail-closed server-mode auth requirement.
2870fn allow_unauthenticated_server_mode() -> bool {
2871    matches!(
2872        std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
2873        Ok("1" | "true" | "TRUE")
2874    )
2875}
2876
2877/// Fail-closed startup gate: refuse to launch a network-facing server that has no
2878/// authentication configured, unless the operator explicitly accepted the risk.
2879/// Desktop/local mode (`server_mode == false`) is always allowed.
2880fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
2881    server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
2882}
2883
2884/// Whether a bind address exposes the server beyond the local host (i.e. is not a
2885/// loopback address). Binding to a non-loopback address (`0.0.0.0`, `::`, a concrete
2886/// LAN IP, or a hostname) makes the UI reachable by other machines, so the full set
2887/// of server-mode protections must apply even if `--server` was not passed.
2888///
2889/// Fail safe: anything that does not *prove* it is loopback is treated as
2890/// network-facing. A bare hostname or an unparseable address therefore promotes to
2891/// server mode rather than silently running open.
2892fn bind_is_network_facing(bind_address: &str) -> bool {
2893    if let Ok(addr) = bind_address.parse::<SocketAddr>() {
2894        return !addr.ip().is_loopback();
2895    }
2896    // Not a concrete `ip:port`. Strip the port and any IPv6 brackets, then only
2897    // treat the well-known loopback spellings as local; everything else is remote.
2898    let host = bind_address
2899        .rsplit_once(':')
2900        .map_or(bind_address, |(h, _)| h)
2901        .trim_matches(|c| c == '[' || c == ']');
2902    if let Ok(ip) = host.parse::<IpAddr>() {
2903        return !ip.is_loopback();
2904    }
2905    !host.eq_ignore_ascii_case("localhost")
2906}
2907
2908/// Umbrella strict-posture switch (`SLOC_HARDENED=1`). When set, opt-in hardening
2909/// defaults take effect: transport encryption is required on non-loopback binds and
2910/// the auth-lockout threshold tightens. Off by default so existing deployments are
2911/// unaffected; individual controls also keep their own env overrides.
2912fn hardened_mode() -> bool {
2913    std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2914}
2915
2916/// Whether a certificate must be present before serving a network-facing
2917/// (non-loopback) bind. Opt-in via `SLOC_REQUIRE_TLS=1` or `SLOC_HARDENED=1`. Off by
2918/// default, so cleartext and reverse-proxy-terminated deployments keep working.
2919fn require_tls() -> bool {
2920    hardened_mode()
2921        || std::env::var("SLOC_REQUIRE_TLS")
2922            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2923}
2924
2925/// Optional sliding idle timeout for authenticated sessions. `None` (the default)
2926/// means only the 8-hour absolute cap applies — identical to prior behaviour.
2927/// `SLOC_SESSION_IDLE_SECS=<n>` sets an explicit idle limit (`0` disables); under
2928/// `SLOC_HARDENED` it defaults to 15 minutes. Each authenticated request refreshes
2929/// the session's last-seen time, so the window slides.
2930pub(crate) fn session_idle_timeout() -> Option<Duration> {
2931    match std::env::var("SLOC_SESSION_IDLE_SECS")
2932        .ok()
2933        .and_then(|v| v.parse::<u64>().ok())
2934    {
2935        Some(0) => None,
2936        Some(secs) => Some(Duration::from_secs(secs)),
2937        None if hardened_mode() => Some(Duration::from_mins(15)),
2938        None => None,
2939    }
2940}
2941
2942/// Generic authorized-use notice shown when a banner is required but the operator
2943/// has not supplied custom text via `SLOC_CONSENT_BANNER`.
2944const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
2945Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
2946are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
2947
2948/// The pre-access consent banner text, if enabled. `SLOC_CONSENT_BANNER=<text>`
2949/// sets custom wording; `SLOC_HARDENED` alone falls back to a generic notice.
2950/// `None` (the default) disables the banner entirely.
2951fn consent_banner_text() -> Option<String> {
2952    if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
2953        let t = t.trim();
2954        if !t.is_empty() {
2955            return Some(t.to_owned());
2956        }
2957    }
2958    hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
2959}
2960
2961/// True when this request is a top-level browser navigation that the consent gate
2962/// should intercept. APIs, assets, webhooks, health checks, and the accept
2963/// endpoint itself are never gated.
2964fn consent_gate_applies(req: &Request<Body>) -> bool {
2965    const EXEMPT: &[&str] = &[
2966        "/auth/consent",
2967        "/static/",
2968        "/images/",
2969        "/assets/",
2970        "/badge/",
2971        "/healthz",
2972        "/api/",
2973        "/webhooks/",
2974        "/metrics",
2975        "/favicon",
2976        "/llms",
2977    ];
2978    if !matches!(
2979        *req.method(),
2980        axum::http::Method::GET | axum::http::Method::HEAD
2981    ) {
2982        return false;
2983    }
2984    let is_html = req
2985        .headers()
2986        .get(header::ACCEPT)
2987        .and_then(|v| v.to_str().ok())
2988        .is_some_and(|a| a.contains("text/html"));
2989    if !is_html {
2990        return false;
2991    }
2992    let path = req.uri().path();
2993    !EXEMPT.iter().any(|p| path.starts_with(p))
2994}
2995
2996/// Whether the request already carries the consent acknowledgement cookie.
2997fn request_has_consent(req: &Request<Body>) -> bool {
2998    req.headers()
2999        .get(header::COOKIE)
3000        .and_then(|v| v.to_str().ok())
3001        .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
3002}
3003
3004/// Pre-access consent gate. When a banner is configured, browser page navigations
3005/// must acknowledge it (recorded in a session cookie) before proceeding. A no-op
3006/// when unconfigured, so default deployments are unaffected.
3007async fn consent_gate(req: Request<Body>, next: Next) -> Response {
3008    let Some(text) = consent_banner_text() else {
3009        return next.run(req).await;
3010    };
3011    if !consent_gate_applies(&req) || request_has_consent(&req) {
3012        return next.run(req).await;
3013    }
3014    let nonce = req
3015        .extensions()
3016        .get::<CspNonce>()
3017        .map(|c| c.0.clone())
3018        .unwrap_or_default();
3019    let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
3020    render_consent_page(&text, next_path, &nonce)
3021}
3022
3023/// Minimal escaping for embedding operator/config text into the banner HTML.
3024fn html_escape_consent(s: &str) -> String {
3025    s.replace('&', "&amp;")
3026        .replace('<', "&lt;")
3027        .replace('>', "&gt;")
3028        .replace('"', "&quot;")
3029}
3030
3031/// Render the consent interstitial with an "I Agree" action that records
3032/// acknowledgement and returns the user to where they were headed.
3033fn render_consent_page(text: &str, next_path: &str, nonce: &str) -> Response {
3034    // Only accept a safe same-origin relative path as the return target.
3035    let safe_next = if next_path.starts_with('/')
3036        && !next_path.starts_with("//")
3037        && !next_path.contains("://")
3038        && !next_path.starts_with("/auth/")
3039    {
3040        next_path
3041    } else {
3042        "/"
3043    };
3044    let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
3045    let body = format!(
3046        r#"<!doctype html><html><head><meta charset="utf-8">
3047<meta name="viewport" content="width=device-width, initial-scale=1">
3048<title>Notice and Consent — OxideSLOC</title>
3049<style nonce="{nonce}">body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
3050h1{{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}}
3051.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
3052.agree:hover{{background:#a04d27}}</style>
3053</head><body>
3054<h1>Notice and Consent</h1>
3055<div class="notice">{}</div>
3056<a class="agree" href="{}">I Agree</a>
3057</body></html>"#,
3058        html_escape_consent(text),
3059        accept_url,
3060        nonce = nonce
3061    );
3062    (StatusCode::OK, Html(body)).into_response()
3063}
3064
3065// ── Host-header allowlist (anti DNS-rebinding / Host injection) ────────────────
3066
3067/// Extract the host authority (scheme-less `host` or `host:port`) from a URL string,
3068/// lowercased. Returns `None` when the input has no parseable host.
3069fn host_of_url(url: &str) -> Option<String> {
3070    let after_scheme = url.split("://").nth(1).unwrap_or(url);
3071    let authority = after_scheme
3072        .split(['/', '?', '#'])
3073        .next()
3074        .unwrap_or("")
3075        .trim();
3076    // Strip any userinfo (`user@host`) but keep an IPv6 literal's own colons intact.
3077    let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
3078    (!authority.is_empty()).then(|| authority.to_ascii_lowercase())
3079}
3080
3081/// Parse `SLOC_ALLOWED_HOSTS` (comma- or whitespace-separated) into a normalized list
3082/// of allowed host authorities. Each entry is `host` or `host:port`, lowercased.
3083fn parse_allowed_hosts(raw: &str) -> Vec<String> {
3084    raw.split(',')
3085        .flat_map(str::split_whitespace)
3086        .map(|s| s.trim().to_ascii_lowercase())
3087        .filter(|s| !s.is_empty())
3088        .collect()
3089}
3090
3091/// Whether a request `Host` header value matches the allowlist. An allowlist entry
3092/// without a port matches the request host regardless of its port; an entry with a
3093/// port must match exactly. Case-insensitive.
3094fn host_is_allowed(host_header: &str, allowed: &[String]) -> bool {
3095    let h = host_header.trim().to_ascii_lowercase();
3096    if h.is_empty() {
3097        return false;
3098    }
3099    // Host without the port, being careful not to strip the colons of an IPv6 literal.
3100    let h_noport = if h.starts_with('[') {
3101        h.split(']')
3102            .next()
3103            .map_or(h.as_str(), |b| b)
3104            .trim_start_matches('[')
3105    } else {
3106        h.rsplit_once(':').map_or(h.as_str(), |(host, _)| host)
3107    };
3108    allowed
3109        .iter()
3110        .any(|a| a == &h || a == h_noport || a.trim_matches(|c| c == '[' || c == ']') == h_noport)
3111}
3112
3113/// Paths always answered regardless of the Host allowlist so load-balancer / uptime
3114/// probes (which often hit the raw IP) and HMAC-authenticated webhooks keep working.
3115fn host_check_exempt(path: &str) -> bool {
3116    matches!(path, "/healthz" | "/readyz" | "/metrics") || path.starts_with("/webhooks/")
3117}
3118
3119/// Resolve the effective Host allowlist for server mode: the explicit
3120/// `SLOC_ALLOWED_HOSTS` entries, plus the host of `SLOC_PUBLIC_URL` when set (so the
3121/// canonical hostname is implicitly trusted). Returns `None` when `SLOC_ALLOWED_HOSTS`
3122/// is unset/empty — enforcement is strictly opt-in, so IP-based access is never broken
3123/// by accident. Setting only `SLOC_PUBLIC_URL` does not, by itself, turn enforcement on.
3124fn effective_allowed_hosts() -> Option<Vec<String>> {
3125    let raw = std::env::var("SLOC_ALLOWED_HOSTS").ok()?;
3126    let mut allowed = parse_allowed_hosts(&raw);
3127    if allowed.is_empty() {
3128        return None;
3129    }
3130    if let Some(host) = std::env::var("SLOC_PUBLIC_URL")
3131        .ok()
3132        .as_deref()
3133        .and_then(host_of_url)
3134        && !allowed.contains(&host)
3135    {
3136        allowed.push(host);
3137    }
3138    Some(allowed)
3139}
3140
3141/// Middleware: in server mode, reject requests whose `Host` header is not on the
3142/// allowlist (`SLOC_ALLOWED_HOSTS`). This blocks DNS-rebinding and Host-header
3143/// injection against a network-facing deployment. A no-op when the allowlist is unset
3144/// or in desktop mode, so default deployments and IP access are unaffected.
3145async fn host_allowlist_guard(
3146    State(state): State<AppState>,
3147    req: Request<Body>,
3148    next: Next,
3149) -> Response {
3150    if !state.server_mode || host_check_exempt(req.uri().path()) {
3151        return next.run(req).await;
3152    }
3153    let Some(allowed) = effective_allowed_hosts() else {
3154        return next.run(req).await;
3155    };
3156    let host = req
3157        .headers()
3158        .get(header::HOST)
3159        .and_then(|v| v.to_str().ok())
3160        .unwrap_or("");
3161    if host_is_allowed(host, &allowed) {
3162        return next.run(req).await;
3163    }
3164    audit::record("host_rejected", "denied", &[("host", host)]);
3165    tracing::warn!(
3166        event = "host_rejected",
3167        host,
3168        "Host header not in SLOC_ALLOWED_HOSTS"
3169    );
3170    (
3171        StatusCode::MISDIRECTED_REQUEST,
3172        "421 Misdirected Request — this host is not served here\n",
3173    )
3174        .into_response()
3175}
3176
3177/// Emit operator-facing warnings for insecure server-mode configurations.
3178/// Pure side-effect (stdout); no bearing on the returned config values.
3179// The bools are independent configuration facts read from the resolved config, not
3180// a mode enum — folding them into a struct just to pass them here would add
3181// ceremony without clarity. Scope the allow to this diagnostic helper.
3182#[allow(clippy::fn_params_excessive_bools)]
3183fn emit_server_mode_warnings(
3184    server_mode: bool,
3185    api_keys_empty: bool,
3186    tls_enabled: bool,
3187    trust_proxy: bool,
3188    trusted_proxy_ips: &[IpAddr],
3189) {
3190    if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
3191        // Absence of a key is a hard startup failure in server mode (enforced by the
3192        // caller, `serve`). The only exception is an explicit operator opt-in via
3193        // SLOC_ALLOW_UNAUTHENTICATED=1 for trusted-LAN testing — warn loudly then.
3194        println!(
3195            "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
3196             authentication. Every web endpoint is publicly reachable. Do NOT use this \
3197             outside a trusted, isolated network."
3198        );
3199    }
3200    if server_mode && !tls_enabled {
3201        println!(
3202            "WARNING: TLS is not configured. Traffic is cleartext. \
3203             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
3204             or terminate TLS at a reverse proxy (nginx, caddy)."
3205        );
3206    }
3207    if server_mode {
3208        println!(
3209            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
3210             to restrict cross-origin access (comma-separated)."
3211        );
3212    }
3213    emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
3214    if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
3215        println!(
3216            "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
3217             DISABLED for all git operations. Remove this variable before production use."
3218        );
3219    }
3220}
3221
3222/// Emit the reverse-proxy / X-Forwarded-For trust advisory for server mode.
3223fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
3224    if trust_proxy {
3225        if trusted_proxy_ips.is_empty() {
3226            println!(
3227                "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
3228                 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
3229                 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
3230            );
3231        } else {
3232            println!(
3233                "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
3234                trusted_proxy_ips
3235                    .iter()
3236                    .map(std::string::ToString::to_string)
3237                    .collect::<Vec<_>>()
3238                    .join(", ")
3239            );
3240        }
3241    } else if server_mode {
3242        println!(
3243            "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
3244             (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
3245             proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
3246             enable per-client rate limiting via X-Forwarded-For."
3247        );
3248    }
3249}
3250
3251fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
3252    let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
3253        .or_else(|_| std::env::var("SLOC_API_KEY"))
3254        .unwrap_or_default()
3255        .split(',')
3256        .map(str::trim)
3257        .filter(|s| !s.is_empty())
3258        .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
3259        .collect();
3260    let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
3261        std::env::var("SLOC_API_KEYS_READONLY")
3262            .unwrap_or_default()
3263            .split(',')
3264            .map(str::trim)
3265            .filter(|s| !s.is_empty())
3266            .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
3267            .collect();
3268    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
3269    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
3270    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
3271    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
3272    let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
3273        .unwrap_or_default()
3274        .split(',')
3275        .filter_map(|s| s.trim().parse::<IpAddr>().ok())
3276        .collect();
3277    emit_server_mode_warnings(
3278        server_mode,
3279        api_keys.is_empty(),
3280        tls_enabled,
3281        trust_proxy,
3282        &trusted_proxy_ips,
3283    );
3284    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
3285        .ok()
3286        .and_then(|v| v.parse::<u32>().ok())
3287        .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
3288    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
3289        .ok()
3290        .and_then(|v| v.parse::<u64>().ok())
3291        .unwrap_or(3600);
3292    // Default: 600 req/min in local mode (suits air-gapped/single-user use),
3293    // 120 req/min in server mode (shared network — reduce fuzzing exposure).
3294    // Override with SLOC_RATE_LIMIT=<requests_per_minute>.
3295    let default_rpm: usize = if server_mode { 120 } else { 600 };
3296    let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
3297        .ok()
3298        .and_then(|v| v.parse::<usize>().ok())
3299        .unwrap_or(default_rpm);
3300    let rate_limiter = Arc::new(IpRateLimiter::new(
3301        Duration::from_mins(1),
3302        rate_limit_rpm,
3303        auth_lockout_threshold,
3304        Duration::from_secs(auth_lockout_secs),
3305    ));
3306    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
3307    RuntimeSecurityConfig {
3308        api_keys,
3309        readonly_api_keys,
3310        tls_cert,
3311        tls_key,
3312        tls_enabled,
3313        trust_proxy,
3314        trusted_proxy_ips,
3315        rate_limiter,
3316    }
3317}
3318
3319/// # Errors
3320///
3321/// Returns an error if the server fails to bind to the configured address or
3322/// if the TLS configuration cannot be loaded.
3323///
3324/// # Panics
3325///
3326/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
3327#[allow(clippy::too_many_lines)]
3328/// A network-facing (non-loopback) bind is treated as server mode regardless of the
3329/// `--server` flag: the fail-closed auth gate, the scan-path allowlist, disabled
3330/// desktop-only routes, tighter rate limits, and restricted CORS must all apply the
3331/// moment the UI is reachable off-box. Without this, `SLOC_BIND=0.0.0.0:4317` (or
3332/// `--bind`) with no `--server` would expose an open, allowlist-free filesystem-read
3333/// surface to the whole LAN. Loopback binds keep the open single-user desktop model.
3334/// Mutates `config.web.server_mode` in place and returns the effective flag.
3335fn enforce_network_facing_server_mode(config: &mut AppConfig, bind_address: &str) -> bool {
3336    let mut server_mode = config.web.server_mode;
3337    if !server_mode && bind_is_network_facing(bind_address) {
3338        server_mode = true;
3339        config.web.server_mode = true;
3340        println!(
3341            "NOTE: bind address {bind_address} is network-facing (non-loopback), so \
3342             server-mode protections are being enforced automatically: authentication is \
3343             required, server-side scanning is limited to SLOC_ALLOWED_ROOTS, and \
3344             desktop-only routes are disabled. Pass --server to make this explicit."
3345        );
3346        audit::record(
3347            "server_mode_auto_enabled",
3348            "info",
3349            &[("bind", bind_address)],
3350        );
3351    }
3352    server_mode
3353}
3354
3355/// Bind the preferred address, stepping up through the next 9 ports if it is busy. On Windows a
3356/// killed process can leave its LISTEN socket as an unkillable kernel zombie (visible in netstat
3357/// but owned by no living process); rather than failing we auto-select the next free port.
3358async fn bind_with_port_fallback(
3359    preferred: SocketAddr,
3360    bind_address: &str,
3361) -> Result<(tokio::net::TcpListener, SocketAddr)> {
3362    for offset in 0u16..=9 {
3363        let mut candidate = preferred;
3364        candidate.set_port(preferred.port().saturating_add(offset));
3365        if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
3366            return Ok((l, candidate));
3367        }
3368    }
3369    anyhow::bail!(
3370        "failed to bind local web UI on {} (tried ports {}-{}): all in use",
3371        bind_address,
3372        preferred.port(),
3373        preferred.port().saturating_add(9)
3374    )
3375}
3376
3377/// Terminate TLS natively and serve over HTTPS. Split out of `serve` so the cleartext and
3378/// encrypted paths stay individually simple.
3379async fn serve_https(
3380    cert_path: String,
3381    key_path: String,
3382    listener: tokio::net::TcpListener,
3383    app: Router,
3384    addr: SocketAddr,
3385    server_mode: bool,
3386) -> Result<()> {
3387    let tls_config =
3388        build_tls_config(&cert_path, &key_path).context("failed to load TLS certificate/key")?;
3389    let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
3390
3391    let url = format!("https://{addr}/");
3392    println!("OxideSLOC server running at {url} (TLS)");
3393    if let Some(lan) = wildcard_lan_url(&url) {
3394        println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
3395    }
3396    if server_mode {
3397        emit_hostname_guidance(addr);
3398    }
3399    println!("Use Ctrl+C to stop.");
3400    serve_tls(listener, app, acceptor, server_mode).await
3401}
3402
3403pub async fn serve(mut config: AppConfig) -> Result<()> {
3404    // Anchor the uptime clock at launch so /api/health reports true process uptime.
3405    process_start();
3406    let bind_address = config.web.bind_address.clone();
3407    let server_mode = enforce_network_facing_server_mode(&mut config, &bind_address);
3408    let output_root = resolve_output_root(None);
3409    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
3410    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
3411        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
3412    let mut registry = ScanRegistry::load(&registry_path);
3413    registry.prune_stale();
3414    let _ = registry.save(&registry_path);
3415
3416    let sec = load_runtime_security_config(server_mode);
3417    // Security posture: refuse to start an unauthenticated network-facing server. A server-mode
3418    // launch with no API key would expose every endpoint publicly; fail closed unless the
3419    // operator has explicitly accepted the risk via SLOC_ALLOW_UNAUTHENTICATED=1.
3420    if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
3421        audit::record(
3422            "server_start_refused",
3423            "denied",
3424            &[(
3425                "reason",
3426                "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
3427            )],
3428        );
3429        anyhow::bail!(
3430            "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
3431             (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
3432             unauthenticated server on a trusted, isolated network, explicitly set \
3433             SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
3434        );
3435    }
3436    if server_mode && sec.api_keys.is_empty() {
3437        audit::record("server_start_unauthenticated", "warning", &[]);
3438    }
3439    spawn_upload_staging_cleanup();
3440
3441    let git_clones_dir = resolve_git_clones_dir(&output_root);
3442    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
3443        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
3444    let schedules = ScheduleStore::load(&schedules_path);
3445    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
3446        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
3447    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
3448    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
3449        |_| output_root.join("confluence_config.json"),
3450        PathBuf::from,
3451    );
3452    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
3453    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
3454        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
3455    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
3456    let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
3457        .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
3458    let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
3459
3460    let state = AppState {
3461        base_config: config,
3462        artifacts: Arc::new(Mutex::new(HashMap::new())),
3463        async_runs: Arc::new(Mutex::new(HashMap::new())),
3464        registry: Arc::new(Mutex::new(registry)),
3465        registry_path,
3466        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
3467        server_mode,
3468        allow_unauthenticated: allow_unauthenticated_server_mode(),
3469        tls_enabled: sec.tls_enabled,
3470        api_keys: Arc::new(sec.api_keys),
3471        readonly_api_keys: Arc::new(sec.readonly_api_keys),
3472        rate_limiter: sec.rate_limiter,
3473        trust_proxy: sec.trust_proxy,
3474        trusted_proxy_ips: sec.trusted_proxy_ips,
3475        git_clones_dir,
3476        schedules: Arc::new(Mutex::new(schedules)),
3477        schedules_path,
3478        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
3479        scan_profiles_path,
3480        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
3481        confluence: Arc::new(Mutex::new(confluence)),
3482        confluence_path,
3483        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
3484        watched_dirs_path,
3485        cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
3486        cleanup_policy_path,
3487        cleanup_task_handle: Arc::new(Mutex::new(None)),
3488    };
3489
3490    restart_poll_schedules(&state).await;
3491    warn_insecure_gitlab_webhooks(&state).await;
3492
3493    // Restart auto-cleanup task if a policy was previously saved and is enabled.
3494    {
3495        let enabled = state
3496            .cleanup_policy
3497            .lock()
3498            .await
3499            .policy
3500            .as_ref()
3501            .is_some_and(|p| p.enabled);
3502        if enabled {
3503            let handle = spawn_cleanup_policy_task(state.clone());
3504            *state.cleanup_task_handle.lock().await = Some(handle);
3505        }
3506    }
3507
3508    // Server-mode disk guard: independently enforce the total-size ceiling so a burst
3509    // of uploads/scans can never fill the host disk between age/count policy passes.
3510    if server_mode {
3511        spawn_disk_guard(state.clone());
3512    }
3513
3514    let app = build_router(state.clone());
3515
3516    let preferred: SocketAddr = bind_address
3517        .parse()
3518        .with_context(|| format!("invalid bind address: {bind_address}"))?;
3519
3520    // Opt-in transport-encryption gate: refuse to expose a network-facing (non-
3521    // loopback) listener in cleartext when TLS enforcement is requested. Off by
3522    // default; enable with SLOC_REQUIRE_TLS=1 or SLOC_HARDENED=1. Loopback binds
3523    // (including reverse-proxy-terminated setups) are always allowed.
3524    if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
3525        audit::record(
3526            "server_start_refused",
3527            "denied",
3528            &[("reason", "TLS required for non-loopback bind")],
3529        );
3530        anyhow::bail!(
3531            "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
3532             SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
3533             a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
3534        );
3535    }
3536
3537    let (listener, addr) = bind_with_port_fallback(preferred, &bind_address).await?;
3538    if addr != preferred {
3539        eprintln!(
3540            "NOTE: port {} is blocked by a system socket (Windows zombie); \
3541             using {} instead.",
3542            preferred.port(),
3543            addr.port()
3544        );
3545    }
3546
3547    if sec.tls_enabled {
3548        let cert_path = sec
3549            .tls_cert
3550            .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
3551        let key_path = sec
3552            .tls_key
3553            .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
3554        return serve_https(cert_path, key_path, listener, app, addr, server_mode).await;
3555    }
3556
3557    let url = format!("http://{addr}/");
3558    log_startup_url(&url, server_mode);
3559    if server_mode {
3560        emit_hostname_guidance(addr);
3561    }
3562
3563    axum::serve(
3564        listener,
3565        app.into_make_service_with_connect_info::<SocketAddr>(),
3566    )
3567    .with_graceful_shutdown(shutdown_signal(server_mode))
3568    .await
3569    .context("web server terminated unexpectedly")
3570}
3571
3572/// Discover the primary non-loopback IPv4 address by asking the OS which
3573/// outbound interface it would use to reach a public address.  No packets are
3574/// sent — the UDP socket is only used to query the routing table.
3575fn primary_lan_ip() -> Option<String> {
3576    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3577    socket.connect("8.8.8.8:80").ok()?;
3578    let addr = socket.local_addr().ok()?;
3579    let ip = addr.ip();
3580    if ip.is_loopback() {
3581        return None;
3582    }
3583    Some(ip.to_string())
3584}
3585
3586/// If `url` binds a wildcard address (`0.0.0.0` or `[::]`), return the same URL
3587/// with the primary LAN IP substituted, so the startup log shows a client-usable
3588/// address alongside the bind address. Returns `None` for concrete binds or when
3589/// no routable LAN address can be determined (e.g. loopback-only / no default route).
3590fn wildcard_lan_url(url: &str) -> Option<String> {
3591    if url.contains("0.0.0.0") {
3592        primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
3593    } else if url.contains("[::]") {
3594        primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
3595    } else {
3596        None
3597    }
3598}
3599
3600/// Print the startup URL and, in local mode, open the browser and schedule it.
3601fn log_startup_url(url: &str, server_mode: bool) {
3602    if server_mode {
3603        println!("OxideSLOC server running at {url}");
3604        if let Some(lan) = wildcard_lan_url(url) {
3605            println!("  Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
3606        }
3607        println!("Use Ctrl+C to stop.");
3608    } else {
3609        println!("OxideSLOC local web UI running at {url}");
3610        println!("Press Ctrl+C to stop the server.");
3611        let open_url = url.to_owned();
3612        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
3613    }
3614}
3615
3616/// Split a host authority into `(host, optional port)`, keeping an IPv6 literal's
3617/// bracketed colons intact. `"[::1]:80"` → `("[::1]", Some("80"))`, `"h:80"` →
3618/// `("h", Some("80"))`, `"h"` → `("h", None)`.
3619fn split_host_port(authority: &str) -> (&str, Option<&str>) {
3620    if let Some(rest) = authority.strip_prefix('[') {
3621        // IPv6 literal: host ends at the closing bracket.
3622        if let Some((host, tail)) = rest.split_once(']') {
3623            let port = tail.strip_prefix(':').filter(|p| !p.is_empty());
3624            return (host, port);
3625        }
3626    }
3627    match authority.rsplit_once(':') {
3628        Some((h, p)) if !p.is_empty() => (h, Some(p)),
3629        _ => (authority, None),
3630    }
3631}
3632
3633/// Best-effort startup guidance for a configured canonical hostname (`SLOC_PUBLIC_URL`).
3634/// Prints the canonical URL and performs a DNS-resolution sanity check so the operator
3635/// learns immediately whether LAN clients can reach the server by name. When the name
3636/// does not resolve to an address on this host, it prints the exact `hosts` file line
3637/// and a DNS A-record hint. A no-op when `SLOC_PUBLIC_URL` is unset. Never fails the
3638/// server start — purely advisory.
3639fn emit_hostname_guidance(bound: SocketAddr) {
3640    let Ok(public_url) = std::env::var("SLOC_PUBLIC_URL") else {
3641        return;
3642    };
3643    let public_url = public_url.trim();
3644    if public_url.is_empty() {
3645        return;
3646    }
3647    let Some(authority) = host_of_url(public_url) else {
3648        println!("WARNING: SLOC_PUBLIC_URL='{public_url}' has no parseable host; ignoring it.");
3649        return;
3650    };
3651    let (host, _) = split_host_port(&authority);
3652    println!("Canonical URL: {public_url}");
3653
3654    // Resolve the name and decide whether it points at this machine.
3655    let resolved: Vec<IpAddr> = format!("{host}:{}", bound.port())
3656        .to_socket_addrs()
3657        .map(|it| it.map(|sa| sa.ip()).collect())
3658        .unwrap_or_default();
3659    let local_ips: Vec<IpAddr> = primary_lan_ip()
3660        .and_then(|s| s.parse().ok())
3661        .into_iter()
3662        .chain([
3663            IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
3664            IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
3665        ])
3666        .chain((!bound.ip().is_unspecified()).then_some(bound.ip()))
3667        .collect();
3668
3669    if resolved.is_empty() {
3670        let ip_hint = primary_lan_ip().unwrap_or_else(|| "<this-host-IP>".to_string());
3671        println!(
3672            "  NOTE: '{host}' does not resolve yet. To let LAN clients use the name, either:\n    \
3673             • add a DNS A record:  {host}  ->  {ip_hint}   (on your DNS server), or\n    \
3674             • add a hosts-file line on each client:  {ip_hint}  {host}\n      \
3675             (Windows: C:\\Windows\\System32\\drivers\\etc\\hosts, Linux/macOS: /etc/hosts)"
3676        );
3677    } else if resolved.iter().any(|ip| local_ips.contains(ip)) {
3678        println!("  '{host}' resolves to this host — LAN clients can use the canonical URL.");
3679    } else {
3680        let resolved_list = resolved
3681            .iter()
3682            .map(std::string::ToString::to_string)
3683            .collect::<Vec<_>>()
3684            .join(", ");
3685        println!(
3686            "  WARNING: '{host}' resolves to {resolved_list}, which is not an address on this \
3687             host. Point the DNS record / hosts entry at this machine or clients will reach the \
3688             wrong server."
3689        );
3690    }
3691
3692    if effective_allowed_hosts().is_none() {
3693        println!(
3694            "  TIP: set SLOC_ALLOWED_HOSTS={host} to reject requests with any other Host header \
3695             (blocks DNS-rebinding / Host-header injection)."
3696        );
3697    }
3698}
3699
3700/// Open the given URL in the default system browser.
3701fn open_browser_tab(url: &str) {
3702    // Windows: invoke the URL protocol handler directly via rundll32 rather than
3703    // `cmd /c start`. `cmd.exe` special-cases `&`, `^`, `%` and `start` treats the
3704    // first quoted token as a window title — both are fragile and shell-parsed. The
3705    // url.dll handler receives the URL as a single, non-shell argument.
3706    #[cfg(target_os = "windows")]
3707    let _ = std::process::Command::new("rundll32")
3708        .args(["url.dll,FileProtocolHandler", url])
3709        .stdout(Stdio::null())
3710        .stderr(Stdio::null())
3711        .spawn();
3712    #[cfg(target_os = "macos")]
3713    let _ = std::process::Command::new("open")
3714        .arg(url)
3715        .stdout(Stdio::null())
3716        .stderr(Stdio::null())
3717        .spawn();
3718    #[cfg(target_os = "linux")]
3719    let _ = std::process::Command::new("xdg-open")
3720        .arg(url)
3721        .stdout(Stdio::null())
3722        .stderr(Stdio::null())
3723        .spawn();
3724}
3725
3726/// Graceful-shutdown future: resolves on Ctrl-C.
3727async fn shutdown_signal(server_mode: bool) {
3728    if tokio::signal::ctrl_c().await.is_ok() {
3729        println!();
3730        if server_mode {
3731            println!("Shutting down OxideSLOC server...");
3732        } else {
3733            println!("Shutting down OxideSLOC local web UI...");
3734        }
3735        println!("Server stopped cleanly.");
3736    }
3737}
3738
3739/// Load a rustls `ServerConfig` from PEM certificate and key files.
3740fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
3741    use rustls_pki_types::pem::PemObject;
3742    use rustls_pki_types::{CertificateDer, PrivateKeyDer};
3743
3744    let cert_bytes =
3745        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
3746    let key_bytes =
3747        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
3748
3749    let cert_chain: Vec<CertificateDer<'static>> =
3750        CertificateDer::pem_slice_iter(cert_bytes.as_slice())
3751            .collect::<std::result::Result<_, _>>()
3752            .context("failed to parse TLS certificates")?;
3753
3754    let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
3755        .context("failed to parse TLS private key")?;
3756
3757    // Explicitly pin the accepted protocol versions to TLS 1.2 and 1.3 (these are
3758    // rustls's safe defaults; stated here so the accepted set is auditable). rustls
3759    // ships only modern AEAD cipher suites — no CBC/RC4/3DES — so no suite pinning is
3760    // needed to exclude weak ciphers.
3761    let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
3762        &rustls::version::TLS13,
3763        &rustls::version::TLS12,
3764    ]);
3765
3766    // Opt-in mutual TLS: when SLOC_TLS_CLIENT_CA points to a PEM CA bundle, require
3767    // every client to present a certificate that chains to it — a transport-layer
3768    // factor on top of the application API key. Unset = no client auth (prior
3769    // behaviour).
3770    let config = match client_cert_verifier()? {
3771        Some(verifier) => builder
3772            .with_client_cert_verifier(verifier)
3773            .with_single_cert(cert_chain, key),
3774        None => builder
3775            .with_no_client_auth()
3776            .with_single_cert(cert_chain, key),
3777    };
3778    config.context("failed to build TLS server config")
3779}
3780
3781/// Build a client-certificate verifier when `SLOC_TLS_CLIENT_CA` is configured,
3782/// enabling mutual TLS. Returns `None` (no client auth) when unset — the default.
3783fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
3784    use rustls_pki_types::CertificateDer;
3785    use rustls_pki_types::pem::PemObject;
3786
3787    let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
3788        .ok()
3789        .filter(|s| !s.is_empty())
3790    else {
3791        return Ok(None);
3792    };
3793    let ca_bytes = fs::read(&ca_path)
3794        .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
3795    let mut roots = rustls::RootCertStore::empty();
3796    for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
3797        let cert = cert.context("failed to parse client CA certificate")?;
3798        roots
3799            .add(cert)
3800            .context("failed to add client CA certificate to root store")?;
3801    }
3802    let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
3803        .build()
3804        .context("failed to build client certificate verifier")?;
3805    Ok(Some(verifier))
3806}
3807
3808/// Accept loop with TLS termination using tokio-rustls + hyper-util.
3809async fn serve_tls(
3810    listener: tokio::net::TcpListener,
3811    app: Router,
3812    acceptor: tokio_rustls::TlsAcceptor,
3813    server_mode: bool,
3814) -> Result<()> {
3815    use hyper_util::rt::{TokioExecutor, TokioIo};
3816    use hyper_util::server::conn::auto::Builder as ConnBuilder;
3817    use hyper_util::service::TowerToHyperService;
3818    use tower::{Service, ServiceExt};
3819
3820    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
3821
3822    loop {
3823        tokio::select! {
3824            biased;
3825            _ = tokio::signal::ctrl_c() => {
3826                println!();
3827                if server_mode {
3828                    println!("Shutting down OxideSLOC server...");
3829                } else {
3830                    println!("Shutting down OxideSLOC local web UI...");
3831                }
3832                println!("Server stopped cleanly.");
3833                return Ok(());
3834            }
3835            result = listener.accept() => {
3836                let (tcp, peer_addr) = result.context("TLS accept failed")?;
3837                let acceptor = acceptor.clone();
3838                let mut factory = make_svc.clone();
3839
3840                tokio::spawn(async move {
3841                    let tls = match acceptor.accept(tcp).await {
3842                        Ok(s) => s,
3843                        Err(e) => {
3844                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
3845                            return;
3846                        }
3847                    };
3848                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
3849                        Ok(f) => match Service::call(f, peer_addr).await {
3850                            Ok(s) => s,
3851                            Err(_) => return,
3852                        },
3853                        Err(_) => return,
3854                    };
3855                    let io = TokioIo::new(tls);
3856                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
3857                        .serve_connection(io, TowerToHyperService::new(svc))
3858                        .await
3859                    {
3860                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
3861                    }
3862                });
3863            }
3864        }
3865    }
3866}
3867
3868// auth moved to auth.rs
3869
3870fn build_cors_layer(server_mode: bool) -> CorsLayer {
3871    if server_mode {
3872        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
3873            .unwrap_or_default()
3874            .split(',')
3875            .filter(|s| !s.is_empty())
3876            .filter_map(|s| s.trim().parse().ok())
3877            .collect();
3878        if allowed.is_empty() {
3879            return CorsLayer::new();
3880        }
3881        CorsLayer::new()
3882            .allow_origin(AllowOrigin::list(allowed))
3883            .allow_methods(AllowMethods::list([
3884                axum::http::Method::GET,
3885                axum::http::Method::POST,
3886            ]))
3887            .allow_headers(AllowHeaders::list([
3888                axum::http::header::AUTHORIZATION,
3889                axum::http::header::CONTENT_TYPE,
3890            ]))
3891    } else {
3892        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
3893            let s = origin.to_str().unwrap_or("");
3894            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
3895        }))
3896    }
3897}
3898
3899async fn add_security_headers(
3900    State(state): State<AppState>,
3901    mut req: Request<Body>,
3902    next: Next,
3903) -> Response {
3904    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
3905    req.extensions_mut().insert(CspNonce(nonce.clone()));
3906    let mut resp = next.run(req).await;
3907    inject_page_fade_into_html(&mut resp, &nonce).await;
3908    let h = resp.headers_mut();
3909    // frame-ancestors defaults to deny (the UI cannot be iframed anywhere). An
3910    // operator can opt into embedding in named corporate dashboards by setting
3911    // SLOC_FRAME_ANCESTORS to a space-separated origin allowlist. X-Frame-Options
3912    // cannot express a multi-origin allowlist, so when one is configured we drop
3913    // XFO and let the CSP frame-ancestors directive govern (per-origin, and what
3914    // modern browsers honour); unset keeps the strict XFO: DENY + frame-ancestors
3915    // 'none' posture. A malformed value falls back to the safe default below.
3916    let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
3917        .ok()
3918        .map(|v| v.trim().to_string())
3919        .filter(|v| !v.is_empty());
3920    if frame_ancestors.is_none() {
3921        h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
3922    }
3923    let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
3924    h.insert(
3925        "X-Content-Type-Options",
3926        HeaderValue::from_static("nosniff"),
3927    );
3928    h.insert(
3929        "Referrer-Policy",
3930        HeaderValue::from_static("strict-origin-when-cross-origin"),
3931    );
3932    let csp = format!(
3933        "default-src 'self'; \
3934         base-uri 'self'; \
3935         form-action 'self'; \
3936         style-src 'self' 'nonce-{nonce}'; \
3937         img-src 'self' data: blob:; \
3938         script-src 'self' 'nonce-{nonce}'; \
3939         font-src 'self' data:; \
3940         object-src 'none'; \
3941         frame-ancestors {frame_ancestors_directive}"
3942    );
3943    h.insert(
3944        "Content-Security-Policy",
3945        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
3946            HeaderValue::from_static(
3947                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
3948            )
3949        }),
3950    );
3951    h.insert(
3952        "X-Permitted-Cross-Domain-Policies",
3953        HeaderValue::from_static("none"),
3954    );
3955    h.insert(
3956        "Permissions-Policy",
3957        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
3958    );
3959    h.insert(
3960        "Cross-Origin-Opener-Policy",
3961        HeaderValue::from_static("same-origin"),
3962    );
3963    h.insert(
3964        "Cross-Origin-Resource-Policy",
3965        HeaderValue::from_static("same-origin"),
3966    );
3967    // Every response also carries CORP: same-origin (above), so requiring CORP on embedded
3968    // resources completes cross-origin isolation without blocking the app's own same-origin assets.
3969    h.insert(
3970        "Cross-Origin-Embedder-Policy",
3971        HeaderValue::from_static("require-corp"),
3972    );
3973    if state.tls_enabled {
3974        h.insert(
3975            "Strict-Transport-Security",
3976            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
3977        );
3978    }
3979    resp
3980}
3981
3982/// Anti-CSRF middleware (defence-in-depth beyond `SameSite=Strict`).
3983///
3984/// On state-changing methods, browser-driven cookie-authenticated requests must
3985/// carry an `Origin` (or `Referer`) whose authority matches the server's `Host`.
3986/// This blocks cross-site form/`fetch` POSTs that ride an ambient session cookie.
3987///
3988/// Deliberately exempt:
3989/// * Safe methods (GET/HEAD/OPTIONS/TRACE) — never state-changing.
3990/// * Requests bearing `Authorization: Bearer` / `X-API-Key` — token auth is not
3991///   ambient, so it is not CSRF-exploitable.
3992/// * `/webhooks/*` — authenticated by per-schedule HMAC and legitimately cross-origin.
3993/// * Requests with neither `Origin` nor `Referer` — non-browser clients (curl, CI);
3994///   a browser performing a CSRF attack always sends `Origin`.
3995async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
3996    use axum::http::Method;
3997
3998    let is_state_changing = matches!(
3999        *req.method(),
4000        Method::POST | Method::PUT | Method::PATCH | Method::DELETE
4001    );
4002    let path = req.uri().path();
4003    let has_token_auth = req.headers().contains_key("X-API-Key")
4004        || req
4005            .headers()
4006            .get(header::AUTHORIZATION)
4007            .and_then(|v| v.to_str().ok())
4008            .is_some_and(|v| v.starts_with("Bearer "));
4009
4010    if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
4011        return next.run(req).await;
4012    }
4013
4014    let headers = req.headers();
4015    let header_str = |name: &header::HeaderName| {
4016        headers
4017            .get(name)
4018            .and_then(|v| v.to_str().ok())
4019            .map(str::to_owned)
4020    };
4021    let origin = header_str(&header::ORIGIN);
4022    let referer = header_str(&header::REFERER);
4023    let host = header_str(&header::HOST);
4024
4025    // Extract the authority (host[:port]) from an absolute Origin/Referer URL.
4026    let authority_of = |url: &str| -> Option<String> {
4027        url.split_once("://")
4028            .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
4029    };
4030
4031    let source_authority = origin
4032        .as_deref()
4033        .and_then(authority_of)
4034        .or_else(|| referer.as_deref().and_then(authority_of));
4035
4036    match (source_authority, host) {
4037        // Neither Origin nor Referer present: treat as a non-browser client.
4038        (None, _) => next.run(req).await,
4039        (Some(src), Some(h)) if src == h => next.run(req).await,
4040        (Some(src), host) => {
4041            tracing::warn!(
4042                event = "csrf_rejected",
4043                path = %path,
4044                origin = %src,
4045                host = ?host,
4046                "Cross-origin state-changing request rejected (CSRF guard)"
4047            );
4048            (
4049                StatusCode::FORBIDDEN,
4050                "403 Forbidden — cross-origin request rejected\n",
4051            )
4052                .into_response()
4053        }
4054    }
4055}
4056
4057/// Lightweight fade-in applied to ordinary web-UI pages (Home, Compare Scans,
4058/// Test Metrics, …). These render instantly, so a full spinner "Loading…" screen
4059/// is overkill — a short opacity fade gives a smooth page-to-page transition
4060/// without the heavy overlay. Slow pages (the standalone HTML report) keep the
4061/// branded spinner: they bake in their own `#rpt-loading-overlay` and are skipped
4062/// by `inject_page_fade_into_html`. The early dark-theme apply prevents a
4063/// light-mode flash for dark-theme users.
4064fn page_fade_html(nonce: &str) -> String {
4065    // Fade only the main content (`.page` + footer), leaving the top nav bar, ambient
4066    // watermarks, and code particles persistent across navigation. A plain CSS fade-in
4067    // with NO `fill-mode` and NO JS gating: we must not hold the content at `opacity:0`
4068    // before the animation starts. An `animation: ... both` (or a JS-added `opacity:0`
4069    // class) keeps it invisible from the moment this style parses — at the top of <body> —
4070    // through the entire body parse, which reads as a delay before navigation "begins"
4071    // and then a blink. Without a fill-mode the animation starts at first paint and plays
4072    // 0 -> 1 cleanly, with no pre-paint hold.
4073    const STYLE: &str = r"
4074@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
4075.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
4076body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
4077@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
4078";
4079    // `dark`: apply the saved dark theme before paint to avoid a light flash.
4080    // The click handler gives immediate feedback by fading the *content* out the moment a
4081    // same-origin nav link is clicked, while the top nav stays put. It does NOT call
4082    // preventDefault or delay navigation — the browser navigates instantly and the fade
4083    // plays opportunistically during the natural fetch window, so no latency is added.
4084    // Skips new-tab/modified clicks, downloads, hashes, external links, and same-page
4085    // links. A safety timer + `pageshow` clear the class so content can't get stuck hidden
4086    // if the click was actually a download (no unload) or the page is restored from bfcache.
4087    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');});})();";
4088    format!("<style nonce=\"{nonce}\">{STYLE}</style><script nonce=\"{nonce}\">{JS}</script>")
4089}
4090
4091/// Self-contained branded loading overlay for the heavy comparison pages (Scan
4092/// Delta, Multi-Scan Timeline). Returns a block — its own `<style>`, markup and
4093/// `<script>` — meant to be spliced in immediately after `<body>`.
4094///
4095/// It pairs the spinner with a **visibility gate**: from the first byte the page
4096/// content is held at `visibility:hidden` (only the overlay paints), so the user
4097/// never sees a half-rendered flash while charts/tables are still settling. On
4098/// `load` the gate is lifted to reveal the fully-laid-out page *underneath* the
4099/// still-opaque overlay, which then fades out one frame later — so the reveal is
4100/// of a finished page, with no glitch on either side of the transition.
4101///
4102/// `visibility:hidden` (unlike `display:none`) preserves layout boxes, so charts
4103/// that size themselves from `clientWidth`/`ResizeObserver` render correctly while
4104/// hidden. A `<noscript>` fallback drops the gate and overlay when JS is disabled.
4105fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
4106    const TPL: &str = r#"<style nonce="__N__">
4107html.sloc-pending body{visibility:hidden;}
4108html.sloc-pending #rpt-loading-overlay{visibility:visible;}
4109#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%);}
4110#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
4111body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
4112body.pdf-mode #rpt-loading-overlay{display:none!important;}
4113.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
4114.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;}
4115.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;}
4116@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
4117@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
4118body.dark-theme .rpt-bg-blob{opacity:.36;}
4119.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;}
4120@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
4121body.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);}
4122.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
4123.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
4124.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
4125.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));}
4126@keyframes rpt-spin{to{transform:rotate(360deg);}}
4127.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;}
4128body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
4129body.dark-theme .rpt-spinner-pct{color:#e8932f;}
4130.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
4131.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;}
4132@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
4133.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
4134.rpt-dot:nth-child(2){animation-delay:.28s;}
4135.rpt-dot:nth-child(3){animation-delay:.56s;}
4136@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
4137.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
4138.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
4139.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;}
4140body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
4141@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;}}
4142</style>
4143<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
4144<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>
4145<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
4146  <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
4147  <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
4148  <div class="rpt-load-card">
4149    <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
4150    <div class="rpt-spinner-wrap">
4151      <div class="rpt-spinner-track"></div>
4152      <div class="rpt-spinner"></div>
4153      <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
4154    </div>
4155    <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>
4156    <div class="rpt-status" id="rpt-status">__LABEL__</div>
4157    <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
4158  </div>
4159</div>
4160<script nonce="__N__">
4161(function(){
4162  var ov=document.getElementById('rpt-loading-overlay');
4163  var root=document.documentElement;
4164  function reveal(){root.classList.remove('sloc-pending');}
4165  if(!ov){reveal();return;}
4166  var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
4167  var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
4168  var mi=0,prog=0,done=false,start=Date.now();
4169  // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
4170  // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
4171  // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
4172  var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
4173  function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
4174  function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
4175  setProg(8);
4176  var msgTimer=setInterval(nextMsg,700);
4177  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);
4178  // These pages draw charts into known SVG containers that start empty and are
4179  // filled by JS once layout is available (some only after a ResizeObserver pass
4180  // post-`load`). Treat the page as ready only once every chart container present
4181  // actually has rendered content, so the overlay never lifts on a half-drawn page.
4182  function chartsRendered(){
4183    var sel=['#cmp-tl-svg','#mc-chart'];
4184    for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
4185    return true;
4186  }
4187  function finish(){
4188    if(done)return;done=true;
4189    clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
4190    // Reveal the fully-rendered page under the still-opaque overlay, let it paint
4191    // for two frames, THEN fade the overlay — so no half-rendered state is shown.
4192    reveal();
4193    requestAnimationFrame(function(){requestAnimationFrame(function(){
4194      setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
4195    });});
4196  }
4197  // Wait for `load` (resources + first layout), then poll until the charts have
4198  // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
4199  function afterLoad(){
4200    var loadAt=Date.now();
4201    (function poll(){
4202      if(done)return;
4203      if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
4204        setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
4205        return;
4206      }
4207      requestAnimationFrame(poll);
4208    })();
4209  }
4210  if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
4211  // Absolute safety net: never let the gate/overlay get stuck.
4212  setTimeout(function(){if(!done)finish();},HARD_CAP);
4213})();
4214</script>"#;
4215    TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
4216}
4217
4218/// Shared toast-notification assets + a global PDF-export helper, spliced into
4219/// every page that exports a PDF (Scan Delta, Multi-Scan Timeline, Trend Reports,
4220/// Test Metrics). Returns its own nonce'd `<style>` + `<script>` block, meant to be
4221/// placed just before `</body>`.
4222///
4223/// It defines two globals:
4224/// * `window.slocToast(msg, {type})` — shows a stacked, auto-dismissing toast in the
4225///   bottom-right (`type` = `success` | `error` | `info` | `loading`). A `loading`
4226///   toast stays up until its returned handle's `.dismiss()` is called.
4227/// * `window.slocExportPdf({html, filename, button})` — the single code path for every
4228///   "Export PDF" button: greys the button, shows a loading toast, POSTs to
4229///   `/export/pdf`, triggers the download, then raises a success or error toast and
4230///   restores the button. Centralising this guarantees identical, obvious feedback
4231///   everywhere instead of a silent `alert()`-only failure path.
4232fn sloc_toast_assets(nonce: &str) -> String {
4233    const TPL: &str = r#"<style nonce="__N__">
4234#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;}
4235.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);}
4236.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
4237.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
4238.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;}
4239.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
4240.sloc-toast-error .sloc-toast-ico{background:#b23030;}
4241.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
4242.sloc-toast-success{border-color:#bfe0cc;}
4243.sloc-toast-error{border-color:#e6b3b3;}
4244.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
4245.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;}
4246@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
4247.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;}
4248.sloc-toast-x:hover{opacity:1;}
4249body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
4250body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
4251body.dark-theme .sloc-toast-error{border-color:#6e3434;}
4252body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
4253@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
4254</style>
4255<script nonce="__N__">
4256(function(){
4257  if(window.slocToast)return;
4258  function wrap(){
4259    var w=document.getElementById('sloc-toast-wrap');
4260    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);}
4261    return w;
4262  }
4263  window.slocToast=function(msg,opts){
4264    opts=opts||{};
4265    var type=opts.type||'info';
4266    var loading=type==='loading';
4267    var t=document.createElement('div');
4268    t.className='sloc-toast sloc-toast-'+(loading?'info':type);
4269    t.setAttribute('role',type==='error'?'alert':'status');
4270    var ico=loading
4271      ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
4272      : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
4273    t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
4274    t.querySelector('.sloc-toast-msg').textContent=String(msg);
4275    wrap().appendChild(t);
4276    requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
4277    var gone=false,timer=null;
4278    function close(){
4279      if(gone)return;gone=true;if(timer)clearTimeout(timer);
4280      t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
4281      setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
4282    }
4283    t.querySelector('.sloc-toast-x').addEventListener('click',close);
4284    var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
4285    if(ttl>0)timer=setTimeout(close,ttl);
4286    return {dismiss:close,el:t};
4287  };
4288  window.slocExportPdf=function(o){
4289    o=o||{};
4290    var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
4291    if(btn&&btn.disabled)return;
4292    if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
4293    var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
4294    return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
4295      .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
4296      .then(function(blob){
4297        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
4298        document.body.appendChild(a);a.click();document.body.removeChild(a);
4299        setTimeout(function(){URL.revokeObjectURL(a.href);},400);
4300        load.dismiss();
4301        window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
4302      })
4303      .catch(function(e){
4304        load.dismiss();
4305        window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
4306      })
4307      .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
4308  };
4309})();
4310</script>"#;
4311    TPL.replace("__N__", nonce)
4312}
4313
4314/// Buffer an HTML response body and splice the page fade-in right after the
4315/// opening `<body>` tag. No-op for non-HTML responses or pages that already carry
4316/// an `#rpt-loading-overlay` (e.g. the standalone HTML report, which keeps its
4317/// branded loading spinner for slow renders).
4318async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
4319    let is_html = resp
4320        .headers()
4321        .get(header::CONTENT_TYPE)
4322        .and_then(|v| v.to_str().ok())
4323        .is_some_and(|v| v.starts_with("text/html"));
4324    if !is_html {
4325        return;
4326    }
4327    let body = std::mem::replace(resp.body_mut(), Body::empty());
4328    let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
4329        return;
4330    };
4331    let html = match String::from_utf8(bytes.to_vec()) {
4332        Ok(s) => s,
4333        Err(e) => {
4334            *resp.body_mut() = Body::from(e.into_bytes());
4335            return;
4336        }
4337    };
4338    if html.contains("id=\"rpt-loading-overlay\"") {
4339        *resp.body_mut() = Body::from(html);
4340        return;
4341    }
4342    // Cheap path: our pages always emit a lowercase `<body` tag, so a direct search
4343    // avoids allocating a lowercased copy of the whole document on every request.
4344    // Fall back to a case-insensitive scan only if that fails (rare/never).
4345    let insert_at = html
4346        .find("<body")
4347        .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
4348        .or_else(|| {
4349            let lower = html.to_ascii_lowercase();
4350            lower
4351                .find("<body")
4352                .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
4353        });
4354    let new_html = match insert_at {
4355        Some(at) => {
4356            let mut out = String::with_capacity(html.len() + 1024);
4357            out.push_str(&html[..at]);
4358            out.push_str(&page_fade_html(nonce));
4359            out.push_str(&html[at..]);
4360            out
4361        }
4362        None => html,
4363    };
4364    resp.headers_mut().remove(header::CONTENT_LENGTH);
4365    *resp.body_mut() = Body::from(new_html);
4366}
4367
4368async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
4369    let peer_ip = req
4370        .extensions()
4371        .get::<axum::extract::ConnectInfo<SocketAddr>>()
4372        .map(|c| c.0.ip());
4373
4374    // Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
4375    // explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
4376    // header spoofing from direct connections.
4377    let ip = peer_ip
4378        .and_then(|peer| {
4379            if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
4380                req.headers()
4381                    .get("X-Forwarded-For")
4382                    .and_then(|v| v.to_str().ok())
4383                    .and_then(|s| s.split(',').next())
4384                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
4385            } else {
4386                None
4387            }
4388        })
4389        .or(peer_ip)
4390        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
4391
4392    if !state.rate_limiter.is_allowed(ip) {
4393        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
4394            path = %req.uri().path(), "Rate limit exceeded");
4395        return (
4396            StatusCode::TOO_MANY_REQUESTS,
4397            [(header::RETRY_AFTER, "60")],
4398            "429 Too Many Requests\n",
4399        )
4400            .into_response();
4401    }
4402    next.run(req).await
4403}
4404
4405async fn splash(
4406    State(state): State<AppState>,
4407    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4408) -> impl IntoResponse {
4409    let lan_ip = if state.server_mode {
4410        primary_lan_ip()
4411    } else {
4412        None
4413    };
4414    let port = state
4415        .base_config
4416        .web
4417        .bind_address
4418        .rsplit(':')
4419        .next()
4420        .and_then(|p| p.parse::<u16>().ok())
4421        .unwrap_or(4317);
4422    let has_api_key = !state.api_keys.is_empty();
4423    let template = SplashTemplate {
4424        csp_nonce,
4425        server_mode: state.server_mode,
4426        lan_ip,
4427        port,
4428        version: env!("CARGO_PKG_VERSION"),
4429        has_api_key,
4430    };
4431    Html(
4432        template
4433            .render()
4434            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4435    )
4436}
4437
4438async fn index(
4439    State(state): State<AppState>,
4440    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4441    Query(query): Query<IndexQuery>,
4442) -> impl IntoResponse {
4443    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
4444        let policy = query
4445            .mixed_line_policy
4446            .unwrap_or_else(|| "code_only".to_string());
4447        let behavior = query
4448            .binary_file_behavior
4449            .unwrap_or_else(|| "skip".to_string());
4450        let cfg = ScanConfig {
4451            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4452            path: query.path.unwrap_or_default(),
4453            include_globs: query.include_globs.unwrap_or_default(),
4454            exclude_globs: query.exclude_globs.unwrap_or_default(),
4455            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
4456            mixed_line_policy: policy,
4457            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
4458                != Some("off"),
4459            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
4460            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
4461            vendor_directory_detection: query.vendor_directory_detection.as_deref()
4462                != Some("disabled"),
4463            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
4464            binary_file_behavior: behavior,
4465            output_dir: query.output_dir.unwrap_or_default(),
4466            report_title: query.report_title.unwrap_or_default(),
4467            continuation_line_policy: query
4468                .continuation_line_policy
4469                .unwrap_or_else(default_each_physical_line),
4470            blank_in_block_comment_policy: query
4471                .blank_in_block_comment_policy
4472                .unwrap_or_else(default_count_as_comment),
4473            count_compiler_directives: query.count_compiler_directives.as_deref()
4474                != Some("disabled"),
4475            style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
4476            style_col_threshold: query
4477                .style_col_threshold
4478                .as_deref()
4479                .and_then(|s| s.parse().ok())
4480                .unwrap_or(80),
4481            style_score_threshold: query
4482                .style_score_threshold
4483                .as_deref()
4484                .and_then(|s| s.parse().ok())
4485                .unwrap_or(0),
4486            style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
4487            coverage_file: query.coverage_file.unwrap_or_default(),
4488            cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
4489            complexity_alert: query
4490                .complexity_alert
4491                .as_deref()
4492                .and_then(|s| s.parse().ok())
4493                .unwrap_or(0),
4494            exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
4495            activity_window: query
4496                .activity_window
4497                .as_deref()
4498                .and_then(|s| s.parse().ok())
4499                .unwrap_or(90),
4500            attribution: query.attribution.as_deref() != Some("disabled"),
4501        };
4502        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
4503    } else {
4504        "{}".to_string()
4505    };
4506
4507    let git_repo = query.git_repo.unwrap_or_default();
4508    let git_ref = query.git_ref.unwrap_or_default();
4509
4510    let git_label = make_git_label(&git_repo, &git_ref);
4511    let git_output_dir = if git_label.is_empty() {
4512        String::new()
4513    } else {
4514        desktop_dir().join(&git_label).display().to_string()
4515    };
4516    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
4517    let git_output_dir_json =
4518        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
4519
4520    let template = IndexTemplate {
4521        version: env!("CARGO_PKG_VERSION"),
4522        prefill_json,
4523        csp_nonce,
4524        git_repo,
4525        git_ref,
4526        git_label_json,
4527        git_output_dir_json,
4528        server_mode: state.server_mode,
4529    };
4530
4531    Html(
4532        template
4533            .render()
4534            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4535    )
4536}
4537
4538async fn scan_setup_handler(
4539    State(state): State<AppState>,
4540    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4541) -> impl IntoResponse {
4542    let recent_scans_json = {
4543        let arr: Vec<serde_json::Value> = {
4544            let reg = state.registry.lock().await;
4545            reg.entries
4546                .iter()
4547                .rev()
4548                .take(6)
4549                .map(|e| {
4550                    let run_dir = e
4551                        .html_path
4552                        .as_ref()
4553                        .or(e.json_path.as_ref())
4554                        .and_then(|p| p.parent().map(PathBuf::from));
4555                    let config_val: Option<serde_json::Value> = run_dir
4556                        .and_then(|d| find_scan_config_in_dir(&d))
4557                        .and_then(|p| fs::read_to_string(&p).ok())
4558                        .and_then(|s| serde_json::from_str(&s).ok());
4559                    serde_json::json!({
4560                        "project_label": e.project_label,
4561                        "timestamp": fmt_la_time(e.timestamp_utc),
4562                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
4563                        "config": config_val,
4564                    })
4565                })
4566                .collect()
4567        };
4568        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
4569    };
4570
4571    let template = ScanSetupTemplate {
4572        version: env!("CARGO_PKG_VERSION"),
4573        recent_scans_json,
4574        csp_nonce,
4575    };
4576    Html(
4577        template
4578            .render()
4579            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4580    )
4581}
4582
4583/// Build provenance embedded at compile time by `build.rs`. Falls back to
4584/// "unknown" on air-gapped builds with no git available.
4585const GIT_SHA: &str = env!("OXIDE_SLOC_GIT_SHA");
4586const BUILD_TIME: &str = env!("OXIDE_SLOC_BUILD_TIME");
4587
4588/// Process start instant, anchored the first time it is read. Called once during
4589/// `serve()` startup so uptime is measured from launch, not from the first probe.
4590pub(crate) fn process_start() -> std::time::Instant {
4591    static START: OnceLock<std::time::Instant> = OnceLock::new();
4592    *START.get_or_init(std::time::Instant::now)
4593}
4594
4595fn uptime_seconds() -> u64 {
4596    process_start().elapsed().as_secs()
4597}
4598
4599/// Liveness probe — the process is up and the event loop is servicing requests.
4600/// Deliberately trivial and dependency-free so container/systemd probes stay fast
4601/// and stable. Readiness (dependency health) is `/readyz`; rich status is `/api/health`.
4602async fn healthz() -> &'static str {
4603    "ok"
4604}
4605
4606/// Probe whether a directory is writable by round-tripping a tiny marker file.
4607/// An empty path is treated as writable (nothing to check).
4608fn dir_writable(dir: &std::path::Path) -> bool {
4609    if dir.as_os_str().is_empty() {
4610        return true;
4611    }
4612    let Ok(dir) = sloc_core::reject_traversal(dir) else {
4613        return false;
4614    };
4615    let _ = std::fs::create_dir_all(&dir);
4616    let probe = dir.join(".oxide-sloc-health-probe");
4617    match std::fs::write(&probe, b"") {
4618        Ok(()) => {
4619            let _ = std::fs::remove_file(&probe);
4620            true
4621        }
4622        Err(_) => false,
4623    }
4624}
4625
4626/// Dependency health checks backing `/api/health` and `/readyz`: can we persist
4627/// the registry and write scan artifacts? Returned in stable order.
4628fn health_checks(state: &AppState) -> Vec<(&'static str, bool)> {
4629    let registry_dir = state
4630        .registry_path
4631        .parent()
4632        .map_or_else(|| std::path::Path::new("."), |p| p);
4633    vec![
4634        ("registry_writable", dir_writable(registry_dir)),
4635        (
4636            "output_dir_writable",
4637            dir_writable(&resolve_output_root(None)),
4638        ),
4639    ]
4640}
4641
4642fn checks_to_json(checks: &[(&'static str, bool)]) -> serde_json::Value {
4643    let map: serde_json::Map<String, serde_json::Value> = checks
4644        .iter()
4645        .map(|(k, v)| ((*k).to_owned(), serde_json::Value::Bool(*v)))
4646        .collect();
4647    serde_json::Value::Object(map)
4648}
4649
4650/// Structured health/status endpoint (`/api/health`). Always answers 200 when the
4651/// process is responsive; the `status` field is `"ok"` when every dependency check
4652/// passes and `"degraded"` otherwise. Use `/readyz` for a pass/fail readiness gate.
4653async fn api_health_handler(State(state): State<AppState>) -> impl IntoResponse {
4654    let checks = health_checks(&state);
4655    let all_ok = checks.iter().all(|(_, ok)| *ok);
4656    axum::Json(serde_json::json!({
4657        "status": if all_ok { "ok" } else { "degraded" },
4658        "name": "oxide-sloc",
4659        "version": env!("CARGO_PKG_VERSION"),
4660        "git_sha": GIT_SHA,
4661        "build_time": BUILD_TIME,
4662        "uptime_seconds": uptime_seconds(),
4663        "checks": checks_to_json(&checks),
4664    }))
4665}
4666
4667/// Readiness probe (`/readyz`): 200 when the server can persist state and write
4668/// artifacts, 503 otherwise. Distinct from `/healthz` (liveness) so orchestrators
4669/// can hold traffic off a process that is up but unable to serve real work.
4670async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
4671    let checks = health_checks(&state);
4672    let ready = checks.iter().all(|(_, ok)| *ok);
4673    let code = if ready {
4674        axum::http::StatusCode::OK
4675    } else {
4676        axum::http::StatusCode::SERVICE_UNAVAILABLE
4677    };
4678    (
4679        code,
4680        axum::Json(serde_json::json!({
4681            "status": if ready { "ready" } else { "not_ready" },
4682            "checks": checks_to_json(&checks),
4683        })),
4684    )
4685}
4686
4687async fn api_version_handler() -> impl IntoResponse {
4688    axum::Json(serde_json::json!({
4689        "name": "oxide-sloc",
4690        "version": env!("CARGO_PKG_VERSION"),
4691        "git_sha": GIT_SHA,
4692        "build_time": BUILD_TIME,
4693    }))
4694}
4695
4696// ── Prometheus metrics ────────────────────────────────────────────────────────
4697
4698fn prom_runs_total() -> &'static prometheus::IntCounter {
4699    static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
4700    COUNTER.get_or_init(|| {
4701        prometheus::register_int_counter!(
4702            "oxide_sloc_runs_total",
4703            "Total number of completed analysis runs"
4704        )
4705        .expect("failed to register oxide_sloc_runs_total counter")
4706    })
4707}
4708
4709async fn metrics_handler() -> impl IntoResponse {
4710    use prometheus::Encoder as _;
4711    let mut buf = Vec::new();
4712    let encoder = prometheus::TextEncoder::new();
4713    let _ = encoder.encode(&prometheus::gather(), &mut buf);
4714    (
4715        [(
4716            axum::http::header::CONTENT_TYPE,
4717            "text/plain; version=0.0.4; charset=utf-8",
4718        )],
4719        buf,
4720    )
4721}
4722
4723static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
4724
4725async fn openapi_yaml_handler() -> impl IntoResponse {
4726    (
4727        [(axum::http::header::CONTENT_TYPE, "application/yaml")],
4728        OPENAPI_YAML,
4729    )
4730}
4731
4732static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
4733static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
4734
4735async fn llms_txt_handler() -> impl IntoResponse {
4736    (
4737        [
4738            (
4739                axum::http::header::CONTENT_TYPE,
4740                "text/plain; charset=utf-8",
4741            ),
4742            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
4743        ],
4744        LLMS_TXT,
4745    )
4746}
4747
4748async fn llms_full_txt_handler() -> impl IntoResponse {
4749    (
4750        [
4751            (
4752                axum::http::header::CONTENT_TYPE,
4753                "text/plain; charset=utf-8",
4754            ),
4755            (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
4756        ],
4757        LLMS_FULL_TXT,
4758    )
4759}
4760
4761async fn api_docs_handler(
4762    State(state): State<AppState>,
4763    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4764) -> impl IntoResponse {
4765    let has_api_key = !state.api_keys.is_empty();
4766    Html(
4767        ApiDocsTemplate {
4768            has_api_key,
4769            csp_nonce,
4770            version: env!("CARGO_PKG_VERSION"),
4771        }
4772        .render()
4773        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4774    )
4775}
4776
4777async fn chart_js_handler() -> impl IntoResponse {
4778    (
4779        [
4780            (
4781                header::CONTENT_TYPE,
4782                "application/javascript; charset=utf-8",
4783            ),
4784            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4785        ],
4786        CHART_JS,
4787    )
4788}
4789
4790/// Shared utility stylesheet (`/static/app.css`). Served same-origin so it is
4791/// permitted by `style-src 'self'` without a nonce — part of migrating inline
4792/// `style="…"` attributes off the CSP's `'unsafe-inline'` allowance.
4793async fn app_css_handler() -> impl IntoResponse {
4794    (
4795        [
4796            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
4797            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4798        ],
4799        APP_CSS,
4800    )
4801}
4802
4803/// Shared applier script (`/static/app.js`). Applies `data-sx-style` declarations
4804/// via the CSSOM (not governed by CSP style-src), so data-driven styles need no
4805/// inline `style="…"`. Same-origin, so permitted by `script-src 'self'`.
4806async fn app_js_handler() -> impl IntoResponse {
4807    (
4808        [
4809            (
4810                header::CONTENT_TYPE,
4811                "application/javascript; charset=utf-8",
4812            ),
4813            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4814        ],
4815        APP_JS,
4816    )
4817}
4818
4819async fn report_chart_js_handler() -> impl IntoResponse {
4820    (
4821        [
4822            (
4823                header::CONTENT_TYPE,
4824                "application/javascript; charset=utf-8",
4825            ),
4826            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4827        ],
4828        REPORT_CHART_JS,
4829    )
4830}
4831
4832#[derive(Debug, Deserialize)]
4833struct AnalyzeForm {
4834    path: String,
4835    git_repo: Option<String>,
4836    git_ref: Option<String>,
4837    mixed_line_policy: Option<MixedLinePolicy>,
4838    python_docstrings_as_comments: Option<String>,
4839    generated_file_detection: Option<String>,
4840    minified_file_detection: Option<String>,
4841    vendor_directory_detection: Option<String>,
4842    include_lockfiles: Option<String>,
4843    binary_file_behavior: Option<BinaryFileBehavior>,
4844    output_dir: Option<String>,
4845    report_title: Option<String>,
4846    report_header_footer: Option<String>,
4847    include_globs: Option<String>,
4848    exclude_globs: Option<String>,
4849    submodule_breakdown: Option<String>,
4850    coverage_file: Option<String>,
4851    continuation_line_policy: Option<ContinuationLinePolicy>,
4852    blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
4853    count_compiler_directives: Option<String>,
4854    style_col_threshold: Option<String>,
4855    style_analysis_enabled: Option<String>,
4856    style_score_threshold: Option<String>,
4857    style_lang_scope: Option<String>,
4858    /// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
4859    cocomo_mode: Option<String>,
4860    /// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
4861    complexity_alert: Option<String>,
4862    /// Whether to exclude duplicate files from displayed SLOC totals.
4863    exclude_duplicates: Option<String>,
4864    /// Git activity window in days for the hotspots view. Empty/0 = disabled.
4865    activity_window: Option<String>,
4866    /// Per-author code ownership via git blame. "enabled" turns it on (off by default).
4867    attribution: Option<String>,
4868}
4869
4870#[allow(clippy::struct_excessive_bools)]
4871#[derive(Debug, Serialize, Deserialize, Clone)]
4872struct ScanConfig {
4873    oxide_sloc_version: String,
4874    path: String,
4875    include_globs: String,
4876    exclude_globs: String,
4877    submodule_breakdown: bool,
4878    mixed_line_policy: String,
4879    python_docstrings_as_comments: bool,
4880    generated_file_detection: bool,
4881    minified_file_detection: bool,
4882    vendor_directory_detection: bool,
4883    include_lockfiles: bool,
4884    binary_file_behavior: String,
4885    output_dir: String,
4886    report_title: String,
4887    // IEEE 1045-1992 and advanced fields added in later release
4888    #[serde(default = "default_each_physical_line")]
4889    continuation_line_policy: String,
4890    #[serde(default = "default_count_as_comment")]
4891    blank_in_block_comment_policy: String,
4892    #[serde(default = "default_true_bool")]
4893    count_compiler_directives: bool,
4894    #[serde(default = "default_true_bool")]
4895    style_analysis_enabled: bool,
4896    #[serde(default = "default_style_col_threshold")]
4897    style_col_threshold: u16,
4898    #[serde(default)]
4899    style_score_threshold: u8,
4900    #[serde(default = "default_all_scope")]
4901    style_lang_scope: String,
4902    #[serde(default)]
4903    coverage_file: String,
4904    #[serde(default = "default_organic")]
4905    cocomo_mode: String,
4906    #[serde(default)]
4907    complexity_alert: u32,
4908    #[serde(default)]
4909    exclude_duplicates: bool,
4910    /// Git hotspots activity window in days (on by default; 0 = disabled).
4911    #[serde(default = "default_activity_window")]
4912    activity_window: u32,
4913    /// Per-author code ownership via git blame (on by default).
4914    #[serde(default = "default_true_bool")]
4915    attribution: bool,
4916}
4917
4918const fn default_activity_window() -> u32 {
4919    90
4920}
4921
4922fn default_each_physical_line() -> String {
4923    "each_physical_line".to_string()
4924}
4925fn default_count_as_comment() -> String {
4926    "count_as_comment".to_string()
4927}
4928const fn default_true_bool() -> bool {
4929    true
4930}
4931const fn default_style_col_threshold() -> u16 {
4932    80
4933}
4934fn default_all_scope() -> String {
4935    "all".to_string()
4936}
4937fn default_organic() -> String {
4938    "organic".to_string()
4939}
4940
4941#[derive(Debug, Deserialize, Default)]
4942struct IndexQuery {
4943    path: Option<String>,
4944    include_globs: Option<String>,
4945    exclude_globs: Option<String>,
4946    submodule_breakdown: Option<String>,
4947    mixed_line_policy: Option<String>,
4948    python_docstrings_as_comments: Option<String>,
4949    generated_file_detection: Option<String>,
4950    minified_file_detection: Option<String>,
4951    vendor_directory_detection: Option<String>,
4952    include_lockfiles: Option<String>,
4953    binary_file_behavior: Option<String>,
4954    output_dir: Option<String>,
4955    report_title: Option<String>,
4956    prefilled: Option<String>,
4957    git_repo: Option<String>,
4958    git_ref: Option<String>,
4959    // IEEE 1045-1992 and advanced fields
4960    continuation_line_policy: Option<String>,
4961    blank_in_block_comment_policy: Option<String>,
4962    count_compiler_directives: Option<String>,
4963    style_analysis_enabled: Option<String>,
4964    style_col_threshold: Option<String>,
4965    style_score_threshold: Option<String>,
4966    style_lang_scope: Option<String>,
4967    coverage_file: Option<String>,
4968    cocomo_mode: Option<String>,
4969    complexity_alert: Option<String>,
4970    exclude_duplicates: Option<String>,
4971    activity_window: Option<String>,
4972    attribution: Option<String>,
4973}
4974
4975#[derive(Debug, Deserialize)]
4976struct PreviewQuery {
4977    path: Option<String>,
4978    include_globs: Option<String>,
4979    exclude_globs: Option<String>,
4980}
4981
4982#[cfg(feature = "native-dialog")]
4983#[derive(Debug, Deserialize)]
4984struct PickDirectoryQuery {
4985    kind: Option<String>,
4986    current: Option<String>,
4987}
4988
4989#[cfg(not(feature = "native-dialog"))]
4990#[derive(Debug, Deserialize)]
4991struct PickDirectoryQuery {}
4992
4993#[derive(Debug, Deserialize, Default)]
4994struct ArtifactQuery {
4995    download: Option<String>,
4996}
4997
4998#[cfg(feature = "native-dialog")]
4999#[derive(Debug, Serialize)]
5000struct PickDirectoryResponse {
5001    selected_path: Option<String>,
5002    cancelled: bool,
5003    /// True when the picked folder is itself a local git repository, so the UI can offer a
5004    /// "Browse branches" shortcut into the Git Browser for it. Always false for file picks.
5005    is_git_repo: bool,
5006}
5007
5008/// Open the native folder/file picker (blocking), including the Windows attach-to-foreground focus
5009/// and flash-when-ready dance. Extracted from `pick_directory_handler` so the async handler keeps
5010/// only the guards + response mapping; always invoked inside `spawn_blocking`.
5011#[cfg(feature = "native-dialog")]
5012fn run_directory_dialog(
5013    title: String,
5014    current: Option<String>,
5015    is_coverage: bool,
5016) -> Option<PathBuf> {
5017    // Windows: attach to the foreground thread so the dialog inherits focus,
5018    // and kick off a watcher that flashes the dialog once it appears.
5019    #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5020    let fg_tid = win_dialog_focus::attach_to_foreground();
5021    #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5022    win_dialog_focus::flash_dialog_when_ready(title.clone());
5023
5024    let mut dialog = rfd::FileDialog::new().set_title(&title);
5025    if let Some(current) = current.as_deref() {
5026        let resolved = resolve_input_path(current);
5027        let seed = if resolved.is_dir() {
5028            Some(resolved)
5029        } else {
5030            resolved.parent().map(Path::to_path_buf)
5031        };
5032        if let Some(seed_dir) = seed.filter(|p| p.exists()) {
5033            dialog = dialog.set_directory(seed_dir);
5034        }
5035    }
5036    let result = if is_coverage {
5037        dialog
5038            .add_filter(
5039                "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
5040                &["info", "lcov", "xml", "json"],
5041            )
5042            .pick_file()
5043    } else {
5044        dialog.pick_folder()
5045    };
5046
5047    #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5048    win_dialog_focus::detach_from_foreground(fg_tid);
5049
5050    result
5051}
5052
5053#[cfg(feature = "native-dialog")]
5054async fn pick_directory_handler(
5055    State(state): State<AppState>,
5056    Query(query): Query<PickDirectoryQuery>,
5057) -> Response {
5058    if state.server_mode {
5059        return StatusCode::NOT_FOUND.into_response();
5060    }
5061    // Return immediately without opening a dialog in headless / CI environments.
5062    if std::env::var("SLOC_HEADLESS").is_ok() {
5063        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
5064            .into_response();
5065    }
5066
5067    let is_coverage = query.kind.as_deref() == Some("coverage");
5068    let title = match query.kind.as_deref() {
5069        Some("output") => "Select output directory",
5070        Some("reports") => "Select folder containing saved reports",
5071        Some("coverage") => "Select LCOV coverage file",
5072        _ => "Select project directory",
5073    }
5074    .to_owned();
5075    let current = query.current.clone();
5076
5077    let picked =
5078        tokio::task::spawn_blocking(move || run_directory_dialog(title, current, is_coverage))
5079            .await
5080            .unwrap_or(None);
5081
5082    // Offer the "Browse branches" shortcut only for a picked project folder that is a git repo.
5083    let is_git_repo = !is_coverage
5084        && picked
5085            .as_ref()
5086            .is_some_and(|p| sloc_git::is_local_repo_path(&display_path(p)));
5087    Json(PickDirectoryResponse {
5088        selected_path: picked.as_ref().map(|p| display_path(p)),
5089        cancelled: picked.is_none(),
5090        is_git_repo,
5091    })
5092    .into_response()
5093}
5094
5095#[cfg(not(feature = "native-dialog"))]
5096async fn pick_directory_handler(
5097    State(_state): State<AppState>,
5098    Query(_query): Query<PickDirectoryQuery>,
5099) -> Response {
5100    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
5101}
5102
5103#[cfg(feature = "native-dialog")]
5104async fn pick_file_handler(State(state): State<AppState>) -> Response {
5105    if state.server_mode {
5106        return StatusCode::NOT_FOUND.into_response();
5107    }
5108    if std::env::var("SLOC_HEADLESS").is_ok() {
5109        return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
5110            .into_response();
5111    }
5112    let picked = tokio::task::spawn_blocking(|| {
5113        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5114        let fg_tid = win_dialog_focus::attach_to_foreground();
5115        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5116        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
5117
5118        let result = rfd::FileDialog::new()
5119            .set_title("Select HTML report")
5120            .add_filter("HTML report", &["html"])
5121            .pick_file();
5122
5123        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5124        win_dialog_focus::detach_from_foreground(fg_tid);
5125
5126        result
5127    })
5128    .await
5129    .unwrap_or(None);
5130    Json(PickDirectoryResponse {
5131        selected_path: picked.as_ref().map(|p| display_path(p)),
5132        cancelled: picked.is_none(),
5133        is_git_repo: false,
5134    })
5135    .into_response()
5136}
5137
5138#[cfg(not(feature = "native-dialog"))]
5139async fn pick_file_handler(State(_state): State<AppState>) -> Response {
5140    Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
5141}
5142
5143// ── Browser-upload handlers (server mode only) ────────────────────────────────
5144
5145/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
5146/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
5147fn is_upload_tmp_path(path: &Path) -> bool {
5148    let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
5149    path.starts_with(&upload_root)
5150}
5151
5152/// Returns true when `path` is the built-in sample or test-fixture directory.
5153/// These paths ship with the server binary and are always safe to scan/preview.
5154fn is_sample_path(path: &Path) -> bool {
5155    let root = workspace_root();
5156    path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
5157}
5158
5159/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
5160fn upload_base_dir() -> PathBuf {
5161    std::env::temp_dir().join("oxide-sloc-uploads")
5162}
5163
5164/// Returns the staging path for a given upload id inside the base dir.
5165fn upload_staging_path(id: &str) -> PathBuf {
5166    upload_base_dir().join(id)
5167}
5168
5169/// Validate basic field constraints on a directory-upload request.
5170/// Returns an error `Response` if the request should be rejected immediately.
5171#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
5172fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
5173    const MAX_FILES: usize = 50_000;
5174    if body.files.is_empty() {
5175        return Err((
5176            StatusCode::BAD_REQUEST,
5177            Json(serde_json::json!({"error": "No files received"})),
5178        )
5179            .into_response());
5180    }
5181    if body.files.len() > MAX_FILES {
5182        return Err((
5183            StatusCode::PAYLOAD_TOO_LARGE,
5184            Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
5185        )
5186            .into_response());
5187    }
5188    Ok(())
5189}
5190
5191/// Resolve or create the staging directory for a directory upload.
5192/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
5193fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
5194    match id {
5195        Some(id)
5196            if !id.is_empty()
5197                && id.len() <= 36
5198                && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
5199        {
5200            (id.to_string(), upload_staging_path(id))
5201        }
5202        _ => {
5203            let new_id = uuid::Uuid::new_v4().to_string();
5204            let staging = upload_staging_path(&new_id);
5205            (new_id, staging)
5206        }
5207    }
5208}
5209
5210/// Decode, size-check, and write one uploaded file entry into `staging`.
5211/// Returns `Ok(())` whether the file was written or skipped (bad base64).
5212/// Returns `Err(Response)` for fatal errors; the caller is responsible for
5213/// cleaning up `staging` before propagating the error.
5214#[allow(clippy::result_large_err)]
5215async fn stage_decoded_entry(
5216    entry: &UploadedFile,
5217    staging: &Path,
5218    total_bytes: &mut usize,
5219    project_root: &mut Option<PathBuf>,
5220) -> Result<(), Response> {
5221    const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
5222
5223    let Ok(data) = base64::Engine::decode(
5224        &base64::engine::general_purpose::STANDARD,
5225        entry.content.as_bytes(),
5226    ) else {
5227        return Ok(());
5228    };
5229
5230    *total_bytes += data.len();
5231    if *total_bytes > MAX_TOTAL_BYTES {
5232        return Err((
5233            StatusCode::PAYLOAD_TOO_LARGE,
5234            Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
5235        )
5236            .into_response());
5237    }
5238
5239    let rel = std::path::Path::new(&entry.path);
5240    if project_root.is_none()
5241        && let Some(first) = rel.components().next()
5242    {
5243        *project_root = Some(staging.join(first.as_os_str()));
5244    }
5245
5246    let dest = staging.join(rel);
5247    if let Some(parent) = dest.parent()
5248        && tokio::fs::create_dir_all(parent).await.is_err()
5249    {
5250        return Err((
5251            StatusCode::INTERNAL_SERVER_ERROR,
5252            Json(serde_json::json!({"error": "Failed to create directory structure"})),
5253        )
5254            .into_response());
5255    }
5256
5257    if tokio::fs::write(&dest, &data).await.is_err() {
5258        return Err((
5259            StatusCode::INTERNAL_SERVER_ERROR,
5260            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
5261        )
5262            .into_response());
5263    }
5264
5265    Ok(())
5266}
5267
5268/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
5269/// and path-traversal guard. Returns `(file_count, project_root)` on success or
5270/// an error `Response` on failure (staging dir is cleaned up before returning).
5271// The Err type is a fully-rendered axum `Response`, the crate-wide handler error
5272// convention; boxing it here to satisfy result_large_err would break that pattern.
5273#[allow(clippy::result_large_err)]
5274async fn write_upload_files(
5275    files: &[UploadedFile],
5276    staging: &Path,
5277    upload_id: &str,
5278) -> Result<(usize, Option<PathBuf>), Response> {
5279    let mut total_bytes: usize = 0;
5280    let mut project_root: Option<PathBuf> = None;
5281
5282    for entry in files {
5283        let rel = std::path::Path::new(&entry.path);
5284        if rel
5285            .components()
5286            .any(|c| matches!(c, std::path::Component::ParentDir))
5287        {
5288            // Reject the entire upload on the first path traversal attempt.
5289            let _ = tokio::fs::remove_dir_all(staging).await;
5290            tracing::warn!(
5291                event = "upload_path_traversal",
5292                upload_id = %upload_id,
5293                path = %entry.path,
5294                "Upload rejected: path traversal component detected"
5295            );
5296            return Err((
5297                StatusCode::BAD_REQUEST,
5298                Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
5299            )
5300                .into_response());
5301        }
5302
5303        if let Err(resp) =
5304            stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
5305        {
5306            let _ = tokio::fs::remove_dir_all(staging).await;
5307            return Err(resp);
5308        }
5309    }
5310
5311    Ok((files.len(), project_root))
5312}
5313
5314/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
5315/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
5316fn parse_tarball_size_caps() -> (u64, u64) {
5317    let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
5318        .ok()
5319        .and_then(|v| v.parse().ok())
5320        .unwrap_or(2048_u64)
5321        * 1024
5322        * 1024;
5323    let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
5324        .ok()
5325        .and_then(|v| v.parse().ok())
5326        .unwrap_or(10_240_u64)
5327        * 1024
5328        * 1024;
5329    (compressed, decompressed)
5330}
5331
5332/// HTTP-layer body limit for tarball uploads, matching `SLOC_MAX_TARBALL_MB`.
5333/// Applied via `DefaultBodyLimit::max()` at the route layer so oversized requests
5334/// are rejected before the streaming handler is invoked.
5335fn tarball_http_body_limit_bytes() -> usize {
5336    std::env::var("SLOC_MAX_TARBALL_MB")
5337        .ok()
5338        .and_then(|v| v.parse::<usize>().ok())
5339        .unwrap_or(2048)
5340        .saturating_mul(1024 * 1024)
5341}
5342
5343/// Stream `body` into `dest_path`, enforcing `max_bytes`.
5344/// Returns the number of compressed bytes written, or an error `Response`.
5345/// Cleans up `dest_path` on error.
5346#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
5347async fn stream_body_to_file(
5348    body: axum::body::Body,
5349    dest_path: &Path,
5350    max_bytes: u64,
5351) -> Result<u64, Response> {
5352    use http_body_util::BodyExt as _;
5353    use tokio::io::AsyncWriteExt as _;
5354
5355    let mut file = match tokio::fs::File::create(dest_path).await {
5356        Ok(f) => f,
5357        Err(e) => {
5358            tracing::error!(
5359                event = "upload_io_error",
5360                "failed to create tarball temp file: {e}"
5361            );
5362            return Err((
5363                StatusCode::INTERNAL_SERVER_ERROR,
5364                Json(serde_json::json!({"error": "Upload initialization failed"})),
5365            )
5366                .into_response());
5367        }
5368    };
5369
5370    let mut body = body;
5371    let mut written: u64 = 0;
5372    loop {
5373        match body.frame().await {
5374            None => break,
5375            Some(Err(e)) => {
5376                let _ = tokio::fs::remove_file(dest_path).await;
5377                return Err((
5378                    StatusCode::BAD_REQUEST,
5379                    Json(serde_json::json!({"error": format!("Stream error: {e}")})),
5380                )
5381                    .into_response());
5382            }
5383            Some(Ok(frame)) => {
5384                if let Ok(data) = frame.into_data() {
5385                    written += data.len() as u64;
5386                    if written > max_bytes {
5387                        let _ = tokio::fs::remove_file(dest_path).await;
5388                        return Err((
5389                            StatusCode::PAYLOAD_TOO_LARGE,
5390                            Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
5391                        )
5392                            .into_response());
5393                    }
5394                    if let Err(e) = file.write_all(&data).await {
5395                        let _ = tokio::fs::remove_file(dest_path).await;
5396                        tracing::error!(event = "upload_io_error", "tarball write error: {e}");
5397                        return Err((
5398                            StatusCode::INTERNAL_SERVER_ERROR,
5399                            Json(serde_json::json!({"error": "Upload write failed"})),
5400                        )
5401                            .into_response());
5402                    }
5403                }
5404            }
5405        }
5406    }
5407    drop(file);
5408    Ok(written)
5409}
5410
5411/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
5412/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
5413/// on failure (staging dir is cleaned up before returning).
5414#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
5415async fn extract_tarball_to_staging(
5416    tarball_path: &Path,
5417    staging: &Path,
5418    max_decompressed_bytes: u64,
5419) -> Result<(), Response> {
5420    let staging_clone = staging.to_path_buf();
5421    let tarball_clone = tarball_path.to_path_buf();
5422    let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
5423        let file = std::fs::File::open(&tarball_clone)?;
5424        let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
5425        let limited = SizeLimitReader {
5426            inner: gz,
5427            remaining: max_decompressed_bytes,
5428        };
5429        let mut archive = tar::Archive::new(limited);
5430        archive.set_overwrite(true);
5431        archive.set_preserve_permissions(false);
5432        std::fs::create_dir_all(&staging_clone)?;
5433        archive.unpack(&staging_clone)?;
5434        Ok(())
5435    })
5436    .await;
5437    let _ = tokio::fs::remove_file(tarball_path).await;
5438
5439    match extract_result {
5440        Ok(Ok(())) => Ok(()),
5441        Ok(Err(e)) => {
5442            let _ = tokio::fs::remove_dir_all(staging).await;
5443            let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
5444            tracing::warn!(
5445                event = "upload_extract_error",
5446                "tarball extraction failed: {e:#}"
5447            );
5448            let (status, msg) = if is_size_limit {
5449                (
5450                    StatusCode::PAYLOAD_TOO_LARGE,
5451                    "Archive exceeds the decompressed size limit",
5452                )
5453            } else {
5454                (StatusCode::BAD_REQUEST, "Failed to extract archive")
5455            };
5456            Err((status, Json(serde_json::json!({"error": msg}))).into_response())
5457        }
5458        Err(e) => {
5459            let _ = tokio::fs::remove_dir_all(staging).await;
5460            tracing::error!(
5461                event = "upload_extract_panic",
5462                "tarball extraction task panicked: {e}"
5463            );
5464            Err((
5465                StatusCode::INTERNAL_SERVER_ERROR,
5466                Json(serde_json::json!({"error": "Archive extraction failed"})),
5467            )
5468                .into_response())
5469        }
5470    }
5471}
5472
5473/// If `staging` contains exactly one top-level directory, return its path
5474/// (the common case when the archive was created with `webkitRelativePath`).
5475/// Otherwise return `None`.
5476async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
5477    let mut entries = tokio::fs::read_dir(staging).await.ok()?;
5478    let first = entries.next_entry().await.ok()??;
5479    if !first.path().is_dir() {
5480        return None;
5481    }
5482    if entries.next_entry().await.unwrap_or(None).is_some() {
5483        return None;
5484    }
5485    Some(first.path())
5486}
5487
5488/// Request body for `POST /api/upload-directory`.
5489///
5490/// Each entry carries a relative path (identical to the browser's
5491/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
5492/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
5493/// avoids pulling in a `multipart` library that is not in the vendor archive.
5494#[derive(Deserialize)]
5495struct UploadDirRequest {
5496    files: Vec<UploadedFile>,
5497    /// If provided, append this batch to an existing upload session instead of
5498    /// creating a new staging directory. Must be a plain UUID (no path separators).
5499    upload_id: Option<String>,
5500}
5501
5502#[derive(Deserialize)]
5503struct UploadedFile {
5504    /// `webkitRelativePath` value from the browser File object.
5505    path: String,
5506    /// Raw file bytes encoded as standard base64.
5507    content: String,
5508}
5509
5510/// POST /api/upload-directory
5511///
5512/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
5513/// Saves all files to a temp staging directory preserving their relative paths,
5514/// then returns the server-side root directory path so the caller can populate
5515/// the scan-path field and run a normal analysis.
5516///
5517/// Only available in server mode; returns 404 in local mode (use the native
5518/// rfd dialog instead).
5519async fn upload_directory_handler(
5520    State(state): State<AppState>,
5521    Json(body): Json<UploadDirRequest>,
5522) -> Response {
5523    if !state.server_mode {
5524        return StatusCode::NOT_FOUND.into_response();
5525    }
5526    if let Err(resp) = validate_upload_dir_request(&body) {
5527        return resp;
5528    }
5529    // Disk-cap preflight for the first batch of a new upload (continuation batches
5530    // reuse an existing staging dir and are allowed to finish so no partial upload is
5531    // stranded; the periodic disk guard reclaims anything that overruns).
5532    if body.upload_id.is_none()
5533        && let Some(resp) = reject_when_staging_full(&upload_base_dir()).await
5534    {
5535        return resp;
5536    }
5537    // Reuse an existing staging dir when the client sends a continuation batch,
5538    // otherwise create a fresh one. Validate the id to prevent path traversal.
5539    let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
5540    match write_upload_files(&body.files, &staging, &upload_id).await {
5541        Ok((file_count, project_root)) => {
5542            let scan_root = project_root.unwrap_or_else(|| staging.clone());
5543            Json(serde_json::json!({
5544                "tmp_path": scan_root.to_string_lossy(),
5545                "file_count": file_count,
5546                "upload_id": upload_id.clone()
5547            }))
5548            .into_response()
5549        }
5550        Err(resp) => resp,
5551    }
5552}
5553
5554/// Request body for `POST /api/upload-file`.
5555#[derive(Deserialize)]
5556struct UploadFileRequest {
5557    /// Original filename (used only to preserve the extension).
5558    filename: String,
5559    /// File bytes encoded as standard base64.
5560    content: String,
5561}
5562
5563/// POST /api/upload-file
5564///
5565/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
5566/// Accepts `{ "filename": "…", "content": "<base64>" }`.
5567/// Only available in server mode.
5568async fn upload_file_handler(
5569    State(state): State<AppState>,
5570    Json(body): Json<UploadFileRequest>,
5571) -> Response {
5572    const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
5573
5574    if !state.server_mode {
5575        return StatusCode::NOT_FOUND.into_response();
5576    }
5577
5578    let Ok(data) = base64::Engine::decode(
5579        &base64::engine::general_purpose::STANDARD,
5580        body.content.as_bytes(),
5581    ) else {
5582        return (
5583            StatusCode::BAD_REQUEST,
5584            Json(serde_json::json!({"error": "Invalid base64 content"})),
5585        )
5586            .into_response();
5587    };
5588
5589    if data.len() > MAX_FILE_BYTES {
5590        return (
5591            StatusCode::PAYLOAD_TOO_LARGE,
5592            Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
5593        )
5594            .into_response();
5595    }
5596
5597    // Sanitise: strip any directory component from the filename.
5598    let filename = std::path::Path::new(&body.filename)
5599        .file_name()
5600        .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
5601
5602    if let Some(resp) = reject_when_staging_full(&upload_base_dir()).await {
5603        return resp;
5604    }
5605
5606    let upload_id = uuid::Uuid::new_v4();
5607    let staging = std::env::temp_dir()
5608        .join("oxide-sloc-uploads")
5609        .join(upload_id.to_string());
5610
5611    if tokio::fs::create_dir_all(&staging).await.is_err() {
5612        return (
5613            StatusCode::INTERNAL_SERVER_ERROR,
5614            Json(serde_json::json!({"error": "Failed to create staging directory"})),
5615        )
5616            .into_response();
5617    }
5618
5619    let dest = staging.join(&filename);
5620    if tokio::fs::write(&dest, &data).await.is_err() {
5621        let _ = tokio::fs::remove_dir_all(&staging).await;
5622        return (
5623            StatusCode::INTERNAL_SERVER_ERROR,
5624            Json(serde_json::json!({"error": "Failed to write uploaded file"})),
5625        )
5626            .into_response();
5627    }
5628
5629    Json(serde_json::json!({
5630        "tmp_path": dest.to_string_lossy(),
5631        "upload_id": upload_id.to_string()
5632    }))
5633    .into_response()
5634}
5635
5636/// POST /api/upload-tarball
5637///
5638/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
5639/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
5640/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
5641/// project root. The two size fields power the "Original / Compressed project size" display in the
5642/// web UI.
5643///
5644/// `DefaultBodyLimit::max(SLOC_MAX_TARBALL_MB)` is applied per-route (default 2 048 MB) so
5645/// oversized requests are rejected at the HTTP layer; the streaming handler enforces the same
5646/// cap during decompression. The browser-side JS creates the archive one file at a time using
5647/// the native `CompressionStream('gzip')` API so browser RAM usage stays bounded regardless of
5648/// project size.
5649/// Upload disk-cap preflight. Returns `Some(507)` when the upload staging area at
5650/// `upload_base` already meets or exceeds the operator disk ceiling (`SLOC_MAX_DISK_MB`),
5651/// so a new upload should be refused before any bytes are written. Returns `None` when
5652/// no ceiling is configured or there is still headroom.
5653async fn reject_when_staging_full(upload_base: &Path) -> Option<Response> {
5654    let cap = disk_cap_bytes(None)?;
5655    let base = upload_base.to_path_buf();
5656    let used = tokio::task::spawn_blocking(move || dir_size_bytes(&base))
5657        .await
5658        .unwrap_or(0);
5659    if used >= cap {
5660        tracing::warn!(
5661            event = "upload_rejected_disk_cap",
5662            used,
5663            cap,
5664            "upload staging area is at the disk ceiling; rejecting upload"
5665        );
5666        return Some(
5667            (
5668                StatusCode::INSUFFICIENT_STORAGE,
5669                Json(serde_json::json!({
5670                    "error": "Server upload storage is full. Retry after old runs are cleaned up."
5671                })),
5672            )
5673                .into_response(),
5674        );
5675    }
5676    None
5677}
5678
5679/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
5680/// decompressed. Wraps any `std::io::Read` source.
5681struct SizeLimitReader<R> {
5682    inner: R,
5683    remaining: u64,
5684}
5685impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
5686    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
5687        if self.remaining == 0 {
5688            return Err(std::io::Error::other("decompressed size limit exceeded"));
5689        }
5690        let n = self.inner.read(buf)?;
5691        self.remaining = self.remaining.saturating_sub(n as u64);
5692        Ok(n)
5693    }
5694}
5695
5696async fn upload_tarball_handler(
5697    State(state): State<AppState>,
5698    request: axum::extract::Request,
5699) -> Response {
5700    if !state.server_mode {
5701        return StatusCode::NOT_FOUND.into_response();
5702    }
5703
5704    let upload_id = uuid::Uuid::new_v4().to_string();
5705    let upload_base = upload_base_dir();
5706    let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
5707    let staging = upload_staging_path(&upload_id);
5708    let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
5709
5710    if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
5711        tracing::error!(
5712            event = "upload_io_error",
5713            "failed to create upload base dir: {e}"
5714        );
5715        return (
5716            StatusCode::INTERNAL_SERVER_ERROR,
5717            Json(serde_json::json!({"error": "Upload initialization failed"})),
5718        )
5719            .into_response();
5720    }
5721
5722    // ── 0. Disk-cap preflight ────────────────────────────────────────────────
5723    // Reject a new upload outright when the staging area already sits at or above the
5724    // operator disk ceiling (`SLOC_MAX_DISK_MB`). Combined with the per-request body
5725    // and decompression caps, this bounds how much a client can make the host write.
5726    if let Some(resp) = reject_when_staging_full(&upload_base).await {
5727        let _ = tokio::fs::remove_file(&tarball_path).await;
5728        return resp;
5729    }
5730
5731    // ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
5732    let compressed_bytes =
5733        match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
5734            Ok(n) => n,
5735            Err(resp) => return resp,
5736        };
5737
5738    // ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
5739    if let Err(resp) =
5740        extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
5741    {
5742        return resp;
5743    }
5744
5745    // ── 3. Find the project root inside the staging dir ───────────────────────
5746    // If the tar contained a single top-level directory (the common case when the
5747    // browser uses `webkitRelativePath`), return that as the scan root so the path
5748    // shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
5749    let scan_root = find_single_top_dir(&staging)
5750        .await
5751        .unwrap_or_else(|| staging.clone());
5752
5753    // Compute original (uncompressed) size of the extracted tree.
5754    let original_bytes = tokio::task::spawn_blocking({
5755        let p = scan_root.clone();
5756        move || dir_size_bytes(&p)
5757    })
5758    .await
5759    .unwrap_or(0);
5760
5761    Json(serde_json::json!({
5762        "tmp_path": scan_root.to_string_lossy(),
5763        "upload_id": upload_id,
5764        "compressed_bytes": compressed_bytes,
5765        "original_bytes": original_bytes,
5766    }))
5767    .into_response()
5768}
5769
5770#[derive(Deserialize)]
5771struct LocateReportForm {
5772    file_path: String,
5773    #[serde(default)]
5774    redirect_url: Option<String>,
5775    #[serde(default)]
5776    expected_run_id: Option<String>,
5777}
5778
5779/// Render a view-reports error page and return it as a `Response`.
5780fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
5781    let html = ErrorTemplate {
5782        message: message.into(),
5783        last_report_url: Some("/view-reports".to_string()),
5784        last_report_label: Some("View Reports".to_string()),
5785        run_id: None,
5786        error_code: None,
5787        csp_nonce: csp_nonce.to_owned(),
5788        version: env!("CARGO_PKG_VERSION"),
5789    }
5790    .render()
5791    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
5792    Html(html).into_response()
5793}
5794
5795/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
5796fn registry_entry_from_run(
5797    run: &AnalysisRun,
5798    json_path: PathBuf,
5799    html_path: PathBuf,
5800) -> RegistryEntry {
5801    let project_label = run.input_roots.first().map_or_else(
5802        || "Unknown Project".to_string(),
5803        |r| sanitize_project_label(r),
5804    );
5805    let (scan_os, scan_host, scan_user, scan_ci) = scan_env_fields(run);
5806    RegistryEntry {
5807        run_id: run.tool.run_id.clone(),
5808        timestamp_utc: run.tool.timestamp_utc,
5809        project_label,
5810        input_roots: run.input_roots.clone(),
5811        json_path: Some(json_path),
5812        html_path: Some(html_path),
5813        pdf_path: None,
5814        summary: ScanSummarySnapshot::from(&run.summary_totals),
5815        csv_path: None,
5816        xlsx_path: None,
5817        git_branch: None,
5818        git_commit: None,
5819        git_commit_long: None,
5820        git_author: None,
5821        git_tags: None,
5822        git_nearest_tag: None,
5823        git_commit_date: None,
5824        scan_os,
5825        scan_host,
5826        scan_user,
5827        scan_ci,
5828    }
5829}
5830
5831/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
5832/// immediately without requiring a server restart.
5833pub(crate) async fn register_artifacts_in_registry(
5834    state: &AppState,
5835    label: &str,
5836    run: &AnalysisRun,
5837    artifacts: &RunArtifacts,
5838) {
5839    let Some(json_path) = artifacts.json_path.clone() else {
5840        return;
5841    };
5842    let Some(html_path) = artifacts.html_path.clone() else {
5843        return;
5844    };
5845    let mut entry = registry_entry_from_run(run, json_path, html_path);
5846    entry.project_label = label.to_owned();
5847    {
5848        let mut reg = state.registry.lock().await;
5849        reg.add_entry(entry);
5850        let _ = reg.save(&state.registry_path);
5851    }
5852    maybe_auto_export(artifacts).await;
5853}
5854
5855/// If the operator enabled auto-export (`SLOC_EXPORT_AUTO=1`) and a share target is
5856/// configured (`SLOC_EXPORT_DIR`), publish this run's artifacts to it. Best-effort:
5857/// errors are logged inside the export routine and swallowed here so a failed export
5858/// never breaks scan completion.
5859async fn maybe_auto_export(artifacts: &RunArtifacts) {
5860    if !export_auto_enabled() || export_share_dir().is_none() {
5861        return;
5862    }
5863    let output_dir = artifacts.output_dir.clone();
5864    if output_dir.as_os_str().is_empty() || !output_dir.exists() {
5865        return;
5866    }
5867    // Fire-and-forget: reuse the manual share-export routine and discard its HTTP body.
5868    let _ = export_run_to_share(output_dir).await;
5869}
5870
5871fn is_html_report_file(p: &Path) -> bool {
5872    p.is_file()
5873        && p.extension()
5874            .and_then(|x| x.to_str())
5875            .is_some_and(|x| x.eq_ignore_ascii_case("html"))
5876        && p.file_name()
5877            .and_then(|n| n.to_str())
5878            .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
5879}
5880
5881fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
5882    fs::read_dir(dir)
5883        .ok()?
5884        .flatten()
5885        .map(|e| e.path())
5886        .find(|p| is_html_report_file(p))
5887}
5888
5889fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
5890    if let Some(f) = find_html_report_in_dir(dir) {
5891        return Some(f);
5892    }
5893    if let Ok(rd) = fs::read_dir(dir) {
5894        for entry in rd.flatten() {
5895            let sub = entry.path();
5896            if sub.is_dir()
5897                && let Some(f) = find_html_report_in_dir(&sub)
5898            {
5899                return Some(f);
5900            }
5901        }
5902    }
5903    None
5904}
5905
5906/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
5907/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
5908///
5909/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
5910#[allow(clippy::result_large_err)]
5911fn validate_locate_request(
5912    state: &AppState,
5913    file_path: &str,
5914    csp_nonce: &str,
5915) -> Result<(PathBuf, PathBuf), Response> {
5916    let raw = PathBuf::from(file_path);
5917
5918    // If the user pointed at a directory, find the HTML report inside it (or one level deep).
5919    let html_path = if raw.is_dir() {
5920        let found = find_html_report_in_tree(&raw);
5921        match found {
5922            Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
5923            None => {
5924                return Err(locate_report_error(
5925                    "No HTML report file found in the selected folder.\n\nMake sure you selected \
5926                     the folder that contains your scan output (result_*.html or report_*.html).",
5927                    csp_nonce,
5928                ));
5929            }
5930        }
5931    } else {
5932        let file_ext = raw
5933            .extension()
5934            .and_then(|e| e.to_str())
5935            .unwrap_or("")
5936            .to_ascii_lowercase();
5937        if file_ext != "html" {
5938            return Err(locate_report_error(
5939                "Please select the scan output folder, or an .html report file directly.",
5940                csp_nonce,
5941            ));
5942        }
5943        match fs::canonicalize(&raw) {
5944            Ok(p) => strip_unc_prefix(p),
5945            Err(_) => {
5946                return Err(locate_report_error(
5947                    "Report file not found or path is invalid.",
5948                    csp_nonce,
5949                ));
5950            }
5951        }
5952    };
5953
5954    if state.server_mode {
5955        let output_root = resolve_output_root(None);
5956        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
5957        if !html_path.starts_with(&canonical_root) {
5958            return Err(locate_report_error(
5959                "Report file must be within the configured output directory.",
5960                csp_nonce,
5961            ));
5962        }
5963    }
5964    let parent = match html_path.parent() {
5965        Some(p) => p.to_path_buf(),
5966        None => {
5967            return Err(locate_report_error(
5968                "Report file has no parent directory.",
5969                csp_nonce,
5970            ));
5971        }
5972    };
5973    Ok((html_path, parent))
5974}
5975
5976/// JSON-or-HTML error for `locate_report_handler` error paths.
5977fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
5978    if want_json {
5979        (
5980            StatusCode::UNPROCESSABLE_ENTITY,
5981            axum::Json(serde_json::json!({"ok": false, "message": msg})),
5982        )
5983            .into_response()
5984    } else {
5985        locate_report_error(msg, csp_nonce)
5986    }
5987}
5988
5989/// JSON-or-redirect success for locate/relocate handler success paths.
5990fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
5991    if want_json {
5992        axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
5993    } else {
5994        axum::response::Redirect::to(redirect).into_response()
5995    }
5996}
5997
5998/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
5999/// first parseable run when `expected` is empty).  Returns `(path, run_id)`.
6000fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
6001    for jpath in candidates {
6002        if let Ok(run) = read_json(jpath)
6003            && (expected.is_empty() || run.tool.run_id == expected)
6004        {
6005            return Some((jpath.clone(), run.tool.run_id));
6006        }
6007    }
6008    None
6009}
6010
6011fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
6012    html_path
6013        .parent()
6014        .and_then(|p| p.parent())
6015        .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
6016}
6017
6018fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
6019    let mut hits = collect_result_json_candidates(scan_root);
6020    if hits.is_empty() {
6021        hits = collect_result_json_candidates(parent);
6022    }
6023    hits.sort();
6024    hits
6025}
6026
6027#[allow(clippy::too_many_lines)]
6028async fn locate_report_handler(
6029    State(state): State<AppState>,
6030    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6031    headers: axum::http::HeaderMap,
6032    Form(form): Form<LocateReportForm>,
6033) -> impl IntoResponse {
6034    let want_json = headers
6035        .get(axum::http::header::ACCEPT)
6036        .and_then(|v| v.to_str().ok())
6037        .is_some_and(|v| v.contains("application/json"));
6038
6039    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
6040        Ok(v) => v,
6041        Err(resp) => {
6042            if want_json {
6043                return locate_handler_err(
6044                    true,
6045                    "No HTML report file found in the selected folder. \
6046                     Make sure you selected the folder that contains your \
6047                     scan output (look for the folder with html/, json/, pdf/ subdirs)."
6048                        .to_string(),
6049                    &csp_nonce,
6050                );
6051            }
6052            return resp;
6053        }
6054    };
6055
6056    // Search for result_*.json in the HTML's parent and also its grandparent (handles
6057    // layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
6058    let scan_root_owned = resolve_scan_root(&html_path, &parent);
6059    let scan_root: &Path = &scan_root_owned;
6060    let json_candidates = gather_json_candidates(scan_root, &parent);
6061
6062    // If the expected_run_id was provided, find a JSON that matches it exactly.
6063    let expected_run_id = form
6064        .expected_run_id
6065        .as_deref()
6066        .unwrap_or("")
6067        .trim()
6068        .to_string();
6069
6070    let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
6071
6072    // If we have candidates but none matched the expected run_id, surface a clear error.
6073    if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
6074        let actual = json_candidates
6075            .iter()
6076            .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
6077            .unwrap_or_else(|| "unknown".to_string());
6078        return locate_handler_err(
6079            want_json,
6080            format!(
6081                "This folder contains a different scan.\n\n\
6082                 Expected run ID : {expected_run_id}\n\
6083                 Found run ID    : {actual}\n\n\
6084                 Please select the folder that contains the correct scan output."
6085            ),
6086            &csp_nonce,
6087        );
6088    }
6089
6090    let safe_redirect = form
6091        .redirect_url
6092        .as_deref()
6093        .filter(|u| u.starts_with('/') && !u.starts_with("//"))
6094        .unwrap_or("/view-reports?linked=1")
6095        .to_string();
6096
6097    let mut reg = state.registry.lock().await;
6098
6099    if let Some((json_path, run_id)) = matched_json {
6100        // Match by run_id in the registry (works even after files are moved).
6101        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6102            entry.html_path = Some(html_path);
6103            entry.json_path = Some(json_path);
6104            let _ = reg.save(&state.registry_path);
6105            drop(reg);
6106            // Evict the stale in-memory cache so artifact_handler reads fresh from registry.
6107            state.artifacts.lock().await.remove(&run_id);
6108            return redirect_or_json_ok(want_json, &safe_redirect);
6109        }
6110        // No existing entry — build one from the JSON.
6111        match read_json(&json_path) {
6112            Ok(run) => {
6113                let entry = registry_entry_from_run(&run, json_path, html_path);
6114                reg.add_entry(entry);
6115                let _ = reg.save(&state.registry_path);
6116                drop(reg);
6117                state.artifacts.lock().await.remove(&run_id);
6118                return redirect_or_json_ok(want_json, &safe_redirect);
6119            }
6120            Err(e) => {
6121                drop(reg);
6122                return locate_handler_err(
6123                    want_json,
6124                    format!(
6125                        "Found the scan folder but could not parse the result JSON.\n\n\
6126                         The file may have been saved by an older version of OxideSLOC. \
6127                         Re-running the analysis will create a fresh, compatible record.\n\n\
6128                         Error: {e}"
6129                    ),
6130                    &csp_nonce,
6131                );
6132            }
6133        }
6134    }
6135
6136    // No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
6137    if let Some(entry) = reg
6138        .entries
6139        .iter_mut()
6140        .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
6141    {
6142        entry.html_path = Some(html_path.clone());
6143        let _ = reg.save(&state.registry_path);
6144        drop(reg);
6145        state.artifacts.lock().await.remove(&expected_run_id);
6146        return redirect_or_json_ok(want_json, &safe_redirect);
6147    }
6148
6149    drop(reg);
6150    let hint = if state.server_mode {
6151        String::new()
6152    } else {
6153        format!(
6154            "\n\nSearched folder : {}\nHTML found      : {}",
6155            scan_root.display(),
6156            html_path.display()
6157        )
6158    };
6159    locate_handler_err(
6160        want_json,
6161        format!(
6162            "Could not link this report.\n\n\
6163             No result_*.json was found in the selected folder. \
6164             Make sure you selected the top-level scan output folder \
6165             (the one that contains html/, json/, pdf/ subfolders).{hint}"
6166        ),
6167        &csp_nonce,
6168    )
6169}
6170
6171/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
6172fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
6173    fs::read_dir(dir)
6174        .ok()?
6175        .flatten()
6176        .map(|e| e.path())
6177        .find(|p| {
6178            p.is_file()
6179                && p.file_stem()
6180                    .and_then(|n| n.to_str())
6181                    .is_some_and(|n| n.starts_with("result"))
6182                && p.extension()
6183                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
6184        })
6185}
6186
6187#[derive(Deserialize)]
6188struct LocateReportsDirForm {
6189    folder_path: String,
6190}
6191
6192#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
6193async fn locate_reports_dir_handler(
6194    State(state): State<AppState>,
6195    Form(form): Form<LocateReportsDirForm>,
6196) -> impl IntoResponse {
6197    if state.server_mode {
6198        return StatusCode::NOT_FOUND.into_response();
6199    }
6200    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
6201        Ok(p) => strip_unc_prefix(p),
6202        Err(_) => {
6203            return axum::response::Redirect::to(
6204                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
6205            )
6206            .into_response();
6207        }
6208    };
6209    if !folder.is_dir() {
6210        return axum::response::Redirect::to(
6211            "/view-reports?error=Selected+path+is+not+a+directory.",
6212        )
6213        .into_response();
6214    }
6215
6216    let candidates = collect_result_json_candidates(&folder);
6217
6218    if candidates.is_empty() {
6219        return axum::response::Redirect::to(
6220            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
6221        )
6222        .into_response();
6223    }
6224
6225    let mut linked_count: usize = 0;
6226    let mut reg = state.registry.lock().await;
6227    for json_path in candidates {
6228        let Some(parent) = json_path.parent().map(PathBuf::from) else {
6229            continue;
6230        };
6231        if is_dir_already_registered(&reg, &parent) {
6232            continue;
6233        }
6234        let Some(entry) = build_registry_entry_from_json(json_path) else {
6235            continue;
6236        };
6237        reg.add_entry(entry);
6238        linked_count += 1;
6239    }
6240    let _ = reg.save(&state.registry_path);
6241    drop(reg);
6242
6243    if linked_count == 0 {
6244        return axum::response::Redirect::to(
6245            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
6246        )
6247        .into_response();
6248    }
6249    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
6250}
6251
6252#[derive(Deserialize)]
6253struct RelocateScanForm {
6254    run_id: String,
6255    folder_path: String,
6256    redirect_url: String,
6257}
6258
6259/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
6260/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
6261fn relocate_folder_err(
6262    want_json: bool,
6263    status: StatusCode,
6264    msg: &str,
6265    run_id: &str,
6266    folder_hint: &str,
6267    redirect_url: &str,
6268    csp_nonce: &str,
6269) -> Response {
6270    if want_json {
6271        (
6272            status,
6273            axum::Json(serde_json::json!({"ok": false, "message": msg})),
6274        )
6275            .into_response()
6276    } else {
6277        missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
6278    }
6279}
6280
6281#[allow(clippy::too_many_lines)]
6282async fn relocate_scan_handler(
6283    State(state): State<AppState>,
6284    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6285    headers: axum::http::HeaderMap,
6286    Form(form): Form<RelocateScanForm>,
6287) -> impl IntoResponse {
6288    let want_json = headers
6289        .get(axum::http::header::ACCEPT)
6290        .and_then(|v| v.to_str().ok())
6291        .is_some_and(|v| v.contains("application/json"));
6292    if state.server_mode {
6293        return StatusCode::NOT_FOUND.into_response();
6294    }
6295
6296    let run_id = form.run_id.trim().to_string();
6297    let redirect_url = form.redirect_url.trim().to_string();
6298
6299    let run_exists = {
6300        let reg = state.registry.lock().await;
6301        reg.find_by_run_id(&run_id).is_some()
6302    };
6303    if !run_exists {
6304        if want_json {
6305            return (
6306                StatusCode::NOT_FOUND,
6307                axum::Json(serde_json::json!({
6308                    "ok": false,
6309                    "message": format!("Run ID '{run_id}' not found in registry.")
6310                })),
6311            )
6312                .into_response();
6313        }
6314        let html = ErrorTemplate {
6315            message: format!("Run ID '{run_id}' not found in registry."),
6316            last_report_url: Some("/compare-scans".to_string()),
6317            last_report_label: Some("Compare Scans".to_string()),
6318            run_id: Some(run_id.clone()),
6319            error_code: Some(404),
6320            csp_nonce: csp_nonce.clone(),
6321            version: env!("CARGO_PKG_VERSION"),
6322        }
6323        .render()
6324        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
6325        return Html(html).into_response();
6326    }
6327
6328    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
6329        Ok(p) => strip_unc_prefix(p),
6330        Err(_) => {
6331            return relocate_folder_err(
6332                want_json,
6333                StatusCode::UNPROCESSABLE_ENTITY,
6334                "Folder not found or path is invalid.",
6335                &run_id,
6336                form.folder_path.trim(),
6337                &redirect_url,
6338                &csp_nonce,
6339            );
6340        }
6341    };
6342    if !folder.is_dir() {
6343        return relocate_folder_err(
6344            want_json,
6345            StatusCode::UNPROCESSABLE_ENTITY,
6346            "Selected path is not a directory.",
6347            &run_id,
6348            &folder.display().to_string(),
6349            &redirect_url,
6350            &csp_nonce,
6351        );
6352    }
6353
6354    let json_candidates = find_result_files_by_ext(&folder, "json");
6355    if json_candidates.is_empty() {
6356        let msg = format!(
6357            "No result JSON files found in the selected folder.\nSearched: {}",
6358            folder.display()
6359        );
6360        return relocate_folder_err(
6361            want_json,
6362            StatusCode::UNPROCESSABLE_ENTITY,
6363            &msg,
6364            &run_id,
6365            &folder.display().to_string(),
6366            &redirect_url,
6367            &csp_nonce,
6368        );
6369    }
6370
6371    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
6372        let msg = format!(
6373            "No matching scan found in the selected folder.\n\
6374             The JSON files present do not contain run ID: {run_id}\n\
6375             Searched: {}",
6376            folder.display()
6377        );
6378        return relocate_folder_err(
6379            want_json,
6380            StatusCode::UNPROCESSABLE_ENTITY,
6381            &msg,
6382            &run_id,
6383            &folder.display().to_string(),
6384            &redirect_url,
6385            &csp_nonce,
6386        );
6387    };
6388
6389    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
6390    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
6391    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
6392
6393    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
6394        redirect_url
6395    } else {
6396        "/compare-scans".to_string()
6397    };
6398    redirect_or_json_ok(want_json, &safe_redirect)
6399}
6400
6401fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
6402    let mut out = Vec::new();
6403    collect_scan_files_by_ext(folder, ext, &mut out);
6404    if let Ok(rd) = fs::read_dir(folder) {
6405        for entry in rd.flatten() {
6406            let sub = entry.path();
6407            if sub.is_dir() {
6408                collect_scan_files_by_ext(&sub, ext, &mut out);
6409            }
6410        }
6411    }
6412    out
6413}
6414
6415fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
6416    let Ok(rd) = fs::read_dir(dir) else { return };
6417    for entry in rd.flatten() {
6418        let p = entry.path();
6419        if p.is_file()
6420            && p.file_stem()
6421                .and_then(|n| n.to_str())
6422                .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
6423            && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
6424        {
6425            out.push(p);
6426        }
6427    }
6428}
6429
6430fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
6431    candidates
6432        .iter()
6433        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
6434        .cloned()
6435}
6436
6437/// Return the best folder hint for the relocate page.
6438/// When the JSON file lives in a named subfolder (json/, html/, pdf/, excel/)
6439/// point at the parent — the actual top-level output directory — so the user
6440/// selects the root folder rather than the subfolder.
6441fn output_folder_hint(json_path: &std::path::Path) -> String {
6442    let Some(direct_parent) = json_path.parent() else {
6443        return String::new();
6444    };
6445    let parent_name = direct_parent
6446        .file_name()
6447        .and_then(|n| n.to_str())
6448        .unwrap_or("");
6449    if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
6450        direct_parent.parent().map_or_else(
6451            || direct_parent.display().to_string(),
6452            |p| p.display().to_string(),
6453        )
6454    } else {
6455        direct_parent.display().to_string()
6456    }
6457}
6458
6459async fn update_run_file_paths(
6460    state: &AppState,
6461    run_id: &str,
6462    json_path: PathBuf,
6463    html_path: Option<PathBuf>,
6464    pdf_path: Option<PathBuf>,
6465) {
6466    {
6467        let mut reg = state.registry.lock().await;
6468        if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6469            entry.json_path = Some(json_path.clone());
6470            if let Some(ref hp) = html_path {
6471                entry.html_path = Some(hp.clone());
6472            }
6473            if let Some(ref pp) = pdf_path {
6474                entry.pdf_path = Some(pp.clone());
6475            }
6476        }
6477        let _ = reg.save(&state.registry_path);
6478    }
6479    // Also patch the in-memory artifacts map so the result page picks up the
6480    // new paths without requiring a server restart.
6481    {
6482        let mut map = state.artifacts.lock().await;
6483        if let Some(arts) = map.get_mut(run_id) {
6484            arts.json_path = Some(json_path);
6485            if let Some(hp) = html_path {
6486                arts.html_path = Some(hp);
6487            }
6488            if let Some(pp) = pdf_path {
6489                arts.pdf_path = Some(pp);
6490            }
6491        }
6492    }
6493}
6494
6495fn missing_scan_relocate_response(
6496    message: &str,
6497    run_id: &str,
6498    folder_hint: &str,
6499    redirect_url: &str,
6500    server_mode: bool,
6501    csp_nonce: &str,
6502) -> axum::response::Response {
6503    let html = RelocateScanTemplate {
6504        message: message.to_string(),
6505        run_id: run_id.to_string(),
6506        folder_hint: folder_hint.to_string(),
6507        redirect_url: redirect_url.to_string(),
6508        server_mode,
6509        csp_nonce: csp_nonce.to_owned(),
6510        version: env!("CARGO_PKG_VERSION"),
6511    }
6512    .render()
6513    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
6514    (StatusCode::NOT_FOUND, Html(html)).into_response()
6515}
6516
6517// ── Watched-directory helpers ─────────────────────────────────────────────────
6518
6519/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
6520fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
6521    fs::read_dir(dir)
6522        .ok()?
6523        .flatten()
6524        .map(|e| e.path())
6525        .find(|p| {
6526            p.is_file()
6527                && p.extension()
6528                    .and_then(|e| e.to_str())
6529                    .is_some_and(|e| e.eq_ignore_ascii_case(ext))
6530        })
6531}
6532
6533/// Collect `result*.json` candidates from a single scan subdirectory, covering both the
6534/// legacy flat layout (`<scan_dir>/result*.json`) and the structured one
6535/// (`<scan_dir>/json/result*.json`).
6536fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
6537    let mut out = Vec::new();
6538    if let Some(j) = find_result_json_in_dir(sub) {
6539        out.push(j);
6540    }
6541    let json_sub = sub.join("json");
6542    if json_sub.is_dir()
6543        && let Some(j) = find_result_json_in_dir(&json_sub)
6544    {
6545        out.push(j);
6546    }
6547    out
6548}
6549
6550fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
6551    let mut candidates = Vec::new();
6552    if let Some(j) = find_result_json_in_dir(folder) {
6553        candidates.push(j);
6554    }
6555    let Ok(dir_entries) = fs::read_dir(folder) else {
6556        return candidates;
6557    };
6558    for entry in dir_entries.flatten() {
6559        let sub = entry.path();
6560        if sub.is_dir() {
6561            candidates.extend(subdir_result_json_candidates(&sub));
6562        }
6563    }
6564    candidates
6565}
6566
6567fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
6568    reg.entries.iter().any(|e| {
6569        let dir_match = e
6570            .json_path
6571            .as_ref()
6572            .and_then(|p| p.parent())
6573            .is_some_and(|p| p == parent)
6574            || e.html_path
6575                .as_ref()
6576                .and_then(|p| p.parent())
6577                .is_some_and(|p| p == parent);
6578        dir_match
6579            && (e.json_path.as_ref().is_some_and(|p| p.exists())
6580                || e.html_path.as_ref().is_some_and(|p| p.exists()))
6581    })
6582}
6583
6584fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
6585    let json_dir = json_path.parent()?.to_path_buf();
6586    // If the JSON lives inside a directory named "json", the scan root is its parent
6587    // and other artifacts live in sibling subdirectories (html/, pdf/, excel/).
6588    let (html_path, pdf_path, csv_path, xlsx_path) =
6589        if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
6590            let scan_root = json_dir.parent()?;
6591            let html = find_html_report_in_dir(&scan_root.join("html"))
6592                .or_else(|| find_html_report_in_dir(scan_root));
6593            let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
6594            let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
6595            let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
6596            (html, pdf, csv, xlsx)
6597        } else {
6598            let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
6599                rd.flatten()
6600                    .map(|e| e.path())
6601                    .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
6602            });
6603            (html, None, None, None)
6604        };
6605    let run = read_json(&json_path).ok()?;
6606    let project_label = run.input_roots.first().map_or_else(
6607        || "Unknown Project".to_string(),
6608        |r| sanitize_project_label(r),
6609    );
6610    let (scan_os, scan_host, scan_user, scan_ci) = scan_env_fields(&run);
6611    Some(RegistryEntry {
6612        run_id: run.tool.run_id.clone(),
6613        timestamp_utc: run.tool.timestamp_utc,
6614        project_label,
6615        input_roots: run.input_roots.clone(),
6616        json_path: Some(json_path),
6617        html_path,
6618        pdf_path,
6619        csv_path,
6620        xlsx_path,
6621        summary: ScanSummarySnapshot::from(&run.summary_totals),
6622        git_branch: run.git_branch.clone(),
6623        git_commit: run.git_commit_short.clone(),
6624        git_commit_long: run.git_commit_long.clone(),
6625        git_author: run.git_commit_author.clone(),
6626        git_tags: run.git_tags.clone(),
6627        git_nearest_tag: run.git_nearest_tag.clone(),
6628        git_commit_date: run.git_commit_date.clone(),
6629        scan_os,
6630        scan_host,
6631        scan_user,
6632        scan_ci,
6633    })
6634}
6635
6636/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
6637/// Returns the number of newly linked entries.
6638fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
6639    let mut linked = 0usize;
6640    for json_path in collect_result_json_candidates(folder) {
6641        let Some(parent) = json_path.parent().map(PathBuf::from) else {
6642            continue;
6643        };
6644        if is_dir_already_registered(reg, &parent) {
6645            continue;
6646        }
6647        let Some(entry) = build_registry_entry_from_json(json_path) else {
6648            continue;
6649        };
6650        reg.add_entry(entry);
6651        linked += 1;
6652    }
6653    linked
6654}
6655
6656/// Scan all watched directories (plus the default output root) into `reg`.
6657async fn auto_scan_watched_dirs(state: &AppState) {
6658    let dirs: Vec<PathBuf> = {
6659        let wd = state.watched_dirs.lock().await;
6660        wd.dirs.clone()
6661    };
6662    // Reconcile the registry to the watched-folder model: keep only entries under a
6663    // currently-watched folder or the app's own output directory. This drops leftovers from
6664    // folders that have since been un-watched (which would otherwise linger in the list).
6665    {
6666        let output_root = resolve_output_root(None);
6667        let mut roots: Vec<PathBuf> = dirs.clone();
6668        if let Ok(canon) = fs::canonicalize(&output_root) {
6669            roots.push(strip_unc_prefix(canon));
6670        }
6671        roots.push(output_root);
6672        let mut reg = state.registry.lock().await;
6673        if reg.retain_under_roots(&roots) > 0 {
6674            let _ = reg.save(&state.registry_path);
6675        }
6676    }
6677    if dirs.is_empty() {
6678        return;
6679    }
6680    let mut reg = state.registry.lock().await;
6681    let mut total = 0usize;
6682    for dir in &dirs {
6683        if dir.is_dir() {
6684            total += scan_folder_into_registry(dir, &mut reg);
6685        }
6686    }
6687    if total > 0 {
6688        let _ = reg.save(&state.registry_path);
6689    }
6690}
6691
6692// ── Watched-dir route forms ───────────────────────────────────────────────────
6693
6694#[derive(Deserialize)]
6695struct WatchedDirForm {
6696    folder_path: String,
6697    #[serde(default = "default_redirect")]
6698    redirect_to: String,
6699}
6700
6701fn default_redirect() -> String {
6702    "/view-reports".to_string()
6703}
6704
6705#[derive(Deserialize)]
6706struct WatchedDirRefreshForm {
6707    #[serde(default = "default_redirect")]
6708    redirect_to: String,
6709}
6710
6711// ── Watched-dir helpers ───────────────────────────────────────────────────────
6712
6713/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
6714fn safe_redirect(dest: &str) -> &str {
6715    if dest.starts_with('/') { dest } else { "/" }
6716}
6717
6718// ── Watched-dir handlers ──────────────────────────────────────────────────────
6719
6720async fn add_watched_dir_handler(
6721    State(state): State<AppState>,
6722    Form(form): Form<WatchedDirForm>,
6723) -> impl IntoResponse {
6724    if state.server_mode {
6725        return StatusCode::NOT_FOUND.into_response();
6726    }
6727    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
6728        strip_unc_prefix(p)
6729    } else {
6730        let dest = format!(
6731            "{}?error=Folder+not+found+or+path+is+invalid.",
6732            safe_redirect(&form.redirect_to)
6733        );
6734        return axum::response::Redirect::to(&dest).into_response();
6735    };
6736    if !folder.is_dir() {
6737        let dest = format!(
6738            "{}?error=Selected+path+is+not+a+directory.",
6739            safe_redirect(&form.redirect_to)
6740        );
6741        return axum::response::Redirect::to(&dest).into_response();
6742    }
6743
6744    // Persist the watched directory.
6745    {
6746        let mut wd = state.watched_dirs.lock().await;
6747        wd.add(folder.clone());
6748        let _ = wd.save(&state.watched_dirs_path);
6749    }
6750
6751    // Immediately scan the folder and add any new reports.
6752    let linked = {
6753        let mut reg = state.registry.lock().await;
6754        let n = scan_folder_into_registry(&folder, &mut reg);
6755        if n > 0 {
6756            let _ = reg.save(&state.registry_path);
6757        }
6758        n
6759    };
6760
6761    let dest = if linked > 0 {
6762        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
6763    } else {
6764        format!(
6765            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
6766            safe_redirect(&form.redirect_to)
6767        )
6768    };
6769    axum::response::Redirect::to(&dest).into_response()
6770}
6771
6772async fn remove_watched_dir_handler(
6773    State(state): State<AppState>,
6774    Form(form): Form<WatchedDirForm>,
6775) -> impl IntoResponse {
6776    if state.server_mode {
6777        return StatusCode::NOT_FOUND.into_response();
6778    }
6779    let folder = PathBuf::from(&form.folder_path);
6780    {
6781        let mut wd = state.watched_dirs.lock().await;
6782        wd.remove(&folder);
6783        let _ = wd.save(&state.watched_dirs_path);
6784    }
6785    // Drop any reports that were linked in from this folder so the list reflects the removal.
6786    {
6787        let mut reg = state.registry.lock().await;
6788        if reg.remove_entries_under(&folder) > 0 {
6789            let _ = reg.save(&state.registry_path);
6790        }
6791    }
6792    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
6793}
6794
6795async fn refresh_watched_dirs_handler(
6796    State(state): State<AppState>,
6797    Form(form): Form<WatchedDirRefreshForm>,
6798) -> impl IntoResponse {
6799    if state.server_mode {
6800        return StatusCode::NOT_FOUND.into_response();
6801    }
6802    let dirs: Vec<PathBuf> = {
6803        let wd = state.watched_dirs.lock().await;
6804        wd.dirs.clone()
6805    };
6806    let mut total = 0usize;
6807    {
6808        let mut reg = state.registry.lock().await;
6809        reg.prune_stale();
6810        for dir in &dirs {
6811            if dir.is_dir() {
6812                total += scan_folder_into_registry(dir, &mut reg);
6813            }
6814        }
6815        let _ = reg.save(&state.registry_path);
6816    }
6817    let dest = if total > 0 {
6818        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
6819    } else {
6820        safe_redirect(&form.redirect_to).to_owned()
6821    };
6822    axum::response::Redirect::to(&dest).into_response()
6823}
6824
6825#[derive(Debug, Deserialize)]
6826struct OpenPathQuery {
6827    path: Option<String>,
6828}
6829
6830fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
6831    let mut ancestor = std::path::Path::new(raw);
6832    loop {
6833        match ancestor.parent() {
6834            Some(p) => {
6835                ancestor = p;
6836                if ancestor.is_dir() {
6837                    break;
6838                }
6839            }
6840            None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
6841        }
6842    }
6843    Ok(ancestor.to_path_buf())
6844}
6845
6846async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
6847    match tokio::fs::canonicalize(raw).await {
6848        Ok(canonical) if canonical.is_file() => canonical
6849            .parent()
6850            .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
6851                Ok(p.to_path_buf())
6852            }),
6853        Ok(canonical) if canonical.is_dir() => Ok(canonical),
6854        Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
6855        Err(_) => find_existing_ancestor(raw),
6856    }
6857}
6858
6859async fn open_path_handler(
6860    State(state): State<AppState>,
6861    Query(query): Query<OpenPathQuery>,
6862) -> impl IntoResponse {
6863    if state.server_mode {
6864        return Json(serde_json::json!({
6865            "server_mode_disabled": true,
6866            "message": "Opening a path in the file manager is only available in local desktop mode."
6867        }))
6868        .into_response();
6869    }
6870    // Skip the OS file-manager call in headless / CI environments.
6871    if std::env::var("SLOC_HEADLESS").is_ok() {
6872        return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
6873    }
6874    let raw = match query.path.as_deref() {
6875        Some(p) if !p.is_empty() => p,
6876        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
6877    };
6878
6879    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
6880    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
6881    // so the file explorer still opens somewhere useful.
6882    let target = match resolve_open_target(raw).await {
6883        Ok(p) => p,
6884        Err((code, msg)) => return (code, msg).into_response(),
6885    };
6886
6887    #[cfg(target_os = "windows")]
6888    win_dialog_focus::open_folder_foreground(target);
6889    #[cfg(target_os = "macos")]
6890    let _ = std::process::Command::new("open")
6891        .arg(&target)
6892        .stdout(Stdio::null())
6893        .stderr(Stdio::null())
6894        .spawn();
6895    #[cfg(target_os = "linux")]
6896    {
6897        let folder_name = target
6898            .file_name()
6899            .and_then(|n| n.to_str())
6900            .map(str::to_owned);
6901        let _ = std::process::Command::new("xdg-open")
6902            .arg(&target)
6903            .stdout(Stdio::null())
6904            .stderr(Stdio::null())
6905            .spawn();
6906        // Best-effort: raise the file manager window once it appears.
6907        // wmctrl is common on GNOME/KDE desktops but not guaranteed to be
6908        // installed; failures are silently discarded.
6909        if let Some(name) = folder_name {
6910            std::thread::spawn(move || {
6911                std::thread::sleep(std::time::Duration::from_millis(800));
6912                let _ = std::process::Command::new("wmctrl")
6913                    .args(["-a", &name])
6914                    .stdout(Stdio::null())
6915                    .stderr(Stdio::null())
6916                    .spawn();
6917            });
6918        }
6919    }
6920
6921    Json(serde_json::json!({"ok": true})).into_response()
6922}
6923
6924async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
6925    let (content_type, bytes): (&'static str, &'static [u8]) =
6926        match (folder.as_str(), file.as_str()) {
6927            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
6928            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
6929            ("icons", "c.png") => ("image/png", IMG_ICON_C),
6930            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
6931            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
6932            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
6933            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
6934            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
6935            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
6936            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
6937            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
6938            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
6939            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
6940            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
6941            ("icons", "r.png") => ("image/png", IMG_ICON_R),
6942            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
6943            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
6944            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
6945            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
6946            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
6947            _ => return StatusCode::NOT_FOUND.into_response(),
6948        };
6949    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
6950}
6951
6952/// Server-mode authorization gate for preview paths. Returns `Err(Html(...))` with a
6953/// user-facing rejection message for each disallowed case, or `Ok(())` when the path is
6954/// permitted. Extracted from `preview_handler` to keep that handler's cognitive
6955/// complexity low; the fail-closed semantics are unchanged.
6956fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
6957    // Fail closed: a path that cannot be canonicalised must NOT fall back to the
6958    // raw, un-normalised path for the allowlist check (a textual `starts_with` on
6959    // `<root>/../../etc` would otherwise pass). On resolution failure, only known-safe
6960    // sample/upload locations are permitted; everything else is rejected.
6961    let Ok(canonical) = fs::canonicalize(resolved) else {
6962        if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
6963            return Err(Html(
6964                r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
6965            ));
6966        }
6967        return Ok(());
6968    };
6969    // Upload temp dirs and built-in sample/fixture paths are always safe.
6970    if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
6971        return Ok(());
6972    }
6973    let config = &state.base_config;
6974    if config.discovery.allowed_scan_roots.is_empty() {
6975        return Err(Html(
6976            r#"<div class="preview-error">Preview rejected: this server has no scan roots configured. Set SLOC_ALLOWED_ROOTS (colon-separated paths) to enable server-side path scanning; the Browse / upload flow works without it.</div>"#.to_string()
6977        ));
6978    }
6979    let allowed = path_within_allowed_roots(&canonical, &config.discovery.allowed_scan_roots);
6980    if !allowed {
6981        return Err(Html(
6982            r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
6983        ));
6984    }
6985    Ok(())
6986}
6987
6988async fn preview_handler(
6989    State(state): State<AppState>,
6990    Query(query): Query<PreviewQuery>,
6991) -> impl IntoResponse {
6992    let raw_path = query
6993        .path
6994        .unwrap_or_else(|| "testing/fixtures/basic".to_string());
6995    let resolved = resolve_input_path(&raw_path);
6996
6997    // If the sample path was requested but doesn't exist on this server (e.g. a deployed
6998    // binary whose working directory is not the project root), return a clear message
6999    // instead of an opaque OS error from build_preview_html.
7000    if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
7001        return Html(
7002            r#"<div class="preview-error">Sample directory not available on this server.
7003            Enter a path to a project directory or upload files using Browse.</div>"#
7004                .to_string(),
7005        );
7006    }
7007
7008    if state.server_mode
7009        && let Err(resp) = authorize_preview_path(&state, &resolved)
7010    {
7011        return resp;
7012    }
7013
7014    let include_patterns = split_patterns(query.include_globs.as_deref());
7015    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
7016
7017    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
7018        Ok(html) => Html(html),
7019        Err(err) => Html(format!(
7020            r#"<div class="preview-error">Preview failed: {}</div>"#,
7021            escape_html(&err.to_string())
7022        )),
7023    }
7024}
7025
7026#[derive(Debug, Deserialize, Default)]
7027struct SuggestCoverageQuery {
7028    path: Option<String>,
7029}
7030
7031#[derive(Serialize)]
7032struct SuggestCoverageResponse {
7033    found: Option<String>,
7034    tool: Option<&'static str>,
7035    hint: Option<&'static str>,
7036}
7037
7038async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
7039    const CANDIDATES: &[&str] = &[
7040        // LCOV — cargo-llvm-cov, gcov, lcov
7041        "coverage/lcov.info",
7042        "lcov.info",
7043        "target/llvm-cov/lcov.info",
7044        "target/coverage/lcov.info",
7045        "target/debug/coverage/lcov.info",
7046        "coverage/coverage.lcov",
7047        "build/coverage/lcov.info",
7048        "reports/lcov.info",
7049        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
7050        "coverage.xml",
7051        "coverage/coverage.xml",
7052        "target/site/cobertura/coverage.xml",
7053        "build/reports/coverage/coverage.xml",
7054        // JaCoCo XML — Gradle, Maven JaCoCo plugin
7055        "target/site/jacoco/jacoco.xml",
7056        "build/reports/jacoco/test/jacocoTestReport.xml",
7057        "build/reports/jacoco/jacocoTestReport.xml",
7058        "build/jacoco/jacoco.xml",
7059        // coverage.py native JSON — `coverage json`
7060        "coverage.json",
7061        "coverage/coverage.json",
7062    ];
7063    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
7064    let found = CANDIDATES
7065        .iter()
7066        .map(|rel| root.join(rel))
7067        .find(|p| p.is_file())
7068        .map(|p| display_path(&p));
7069
7070    let (tool, hint) = detect_coverage_tool(&root);
7071    Json(SuggestCoverageResponse { found, tool, hint })
7072}
7073
7074#[derive(Debug, Deserialize, Default)]
7075struct AttribEstimateQuery {
7076    path: Option<String>,
7077}
7078
7079#[derive(Serialize)]
7080struct AttribEstimateResponse {
7081    #[serde(flatten)]
7082    estimate: sloc_core::AttributionEstimate,
7083    /// Human-facing duration hint derived from `estimate.estimated_seconds` (e.g. "~9 min").
7084    estimated_label: String,
7085}
7086
7087/// Non-git / disallowed / empty-path fallback: a zero-cost, attribution-on estimate so the UI
7088/// never blocks a scan on this best-effort signal.
7089fn attrib_estimate_unknown() -> AttribEstimateResponse {
7090    AttribEstimateResponse {
7091        estimate: sloc_core::AttributionEstimate {
7092            is_git: false,
7093            blameable_files: 0,
7094            commit_count: 0,
7095            severity: sloc_core::AttributionSeverity::Light,
7096            recommend_attribution: true,
7097            estimated_seconds: 0,
7098            submodule_count: 0,
7099            combined_commit_count: 0,
7100            branch: None,
7101        },
7102        estimated_label: String::new(),
7103    }
7104}
7105
7106/// Round `secs` into a short human hint: "~40s" under a minute, otherwise "~N min".
7107fn humanize_duration(secs: u64) -> String {
7108    if secs == 0 {
7109        String::new()
7110    } else if secs < 60 {
7111        format!("~{secs}s")
7112    } else {
7113        format!("~{} min", secs.div_ceil(60))
7114    }
7115}
7116
7117/// GET `/api/attribution-estimate?path=…` — a fast, git-metadata-only estimate of how costly the
7118/// per-author attribution (git blame) pass would be, so the scan form can warn the user and
7119/// auto-default attribution off on very large repositories. Best-effort: any failure returns a
7120/// neutral "light" estimate rather than an error.
7121async fn api_attribution_estimate(
7122    State(state): State<AppState>,
7123    Query(query): Query<AttribEstimateQuery>,
7124) -> impl IntoResponse {
7125    let raw_path = query.path.unwrap_or_default();
7126    if raw_path.trim().is_empty() {
7127        return Json(attrib_estimate_unknown());
7128    }
7129    let resolved = resolve_input_path(&raw_path);
7130    // In server mode, never probe a path the caller isn't allowed to scan.
7131    if state.server_mode && authorize_preview_path(&state, &resolved).is_err() {
7132        return Json(attrib_estimate_unknown());
7133    }
7134    // The estimate shells out to git; keep it off the async worker threads.
7135    let estimate =
7136        tokio::task::spawn_blocking(move || sloc_core::estimate_attribution_cost(&resolved)).await;
7137    match estimate {
7138        Ok(est) => {
7139            let estimated_label = humanize_duration(est.estimated_seconds);
7140            Json(AttribEstimateResponse {
7141                estimate: est,
7142                estimated_label,
7143            })
7144        }
7145        Err(_) => Json(attrib_estimate_unknown()),
7146    }
7147}
7148
7149/// Inspect the project root for known build/package files and return the most likely coverage
7150/// tool name and the shell command needed to generate a coverage file.
7151fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
7152    if root.join("Cargo.toml").is_file() {
7153        return (
7154            Some("cargo-llvm-cov"),
7155            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
7156        );
7157    }
7158    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
7159        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
7160    }
7161    if root.join("pom.xml").is_file() {
7162        return (Some("jacoco"), Some("mvn test jacoco:report"));
7163    }
7164    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
7165        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
7166    }
7167    (None, None)
7168}
7169
7170/// True when `canonical` (an already-canonicalized path) resolves under one of
7171/// `allowed_scan_roots`. Each root is canonicalized before the prefix check so `..`/symlink
7172/// tricks in either the path or a configured root cannot slip through. Shared by the
7173/// server-mode scan-path gate, the preview gate, and the Git Browser's local-repo gate.
7174pub(crate) fn path_within_allowed_roots(canonical: &Path, allowed_roots: &[PathBuf]) -> bool {
7175    allowed_roots.iter().any(|root| {
7176        fs::canonicalize(root)
7177            .ok()
7178            .is_some_and(|r| canonical.starts_with(&r))
7179    })
7180}
7181
7182/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
7183/// Build a `403 Forbidden` HTML response from the shared `ErrorTemplate`, falling back to `fallback`
7184/// if template rendering fails. Centralises the boilerplate shared by the scan-path guards.
7185fn forbidden_html_response(message: &str, csp_nonce: &str, fallback: &str) -> Response {
7186    let template = ErrorTemplate {
7187        message: message.to_string(),
7188        last_report_url: None,
7189        last_report_label: None,
7190        run_id: None,
7191        error_code: Some(403),
7192        csp_nonce: csp_nonce.to_owned(),
7193        version: env!("CARGO_PKG_VERSION"),
7194    };
7195    (
7196        StatusCode::FORBIDDEN,
7197        Html(template.render().unwrap_or_else(|_| fallback.to_string())),
7198    )
7199        .into_response()
7200}
7201
7202#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
7203fn validate_server_scan_path(
7204    config: &sloc_config::AppConfig,
7205    resolved_path: &Path,
7206    csp_nonce: &str,
7207) -> Result<(), Response> {
7208    if config.discovery.allowed_scan_roots.is_empty() {
7209        return Err(forbidden_html_response(
7210            "Scan path rejected: this server has no scan roots configured, so \
7211             scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
7212             environment variable (colon-separated absolute paths) — or \
7213             allowed_scan_roots in the config TOML — then restart. Tip: the \
7214             Browse / directory-upload flow works without this; uploaded folders \
7215             are scanned from the server's temp area and bypass this check.",
7216            csp_nonce,
7217            "<pre>Forbidden.</pre>",
7218        ));
7219    }
7220    // Fail closed: if the path cannot be canonicalised (does not resolve to a real
7221    // location) we must NOT fall back to the raw, un-normalised path — a textual
7222    // `starts_with` on an unresolved `<root>/../../etc` would otherwise pass the
7223    // allowlist. A non-resolvable scan target is rejected outright.
7224    let Ok(canonical) = fs::canonicalize(resolved_path) else {
7225        tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
7226            "Scan path does not resolve to a real location");
7227        return Err(forbidden_html_response(
7228            "The requested path could not be resolved to a real directory.",
7229            csp_nonce,
7230            "<pre>Forbidden.</pre>",
7231        ));
7232    };
7233    let allowed = path_within_allowed_roots(&canonical, &config.discovery.allowed_scan_roots);
7234    if !allowed {
7235        tracing::warn!(event = "path_rejected", path = %canonical.display(),
7236            "Scan path not in allowed_scan_roots");
7237        return Err(forbidden_html_response(
7238            "The requested path is not within an allowed scan directory.",
7239            csp_nonce,
7240            "<pre>Path not allowed.</pre>",
7241        ));
7242    }
7243    Ok(())
7244}
7245
7246/// Exclude the output directory from scanning so artifacts don't pollute counts.
7247fn apply_output_dir_exclusions(
7248    config: &mut sloc_config::AppConfig,
7249    project_path: &str,
7250    raw_output_dir: &str,
7251) {
7252    let project_root = resolve_input_path(project_path);
7253    let raw_out = raw_output_dir.trim();
7254    let resolved_out = if raw_out.is_empty() {
7255        project_root.join("sloc")
7256    } else if Path::new(raw_out).is_absolute() {
7257        PathBuf::from(raw_out)
7258    } else {
7259        workspace_root().join(raw_out)
7260    };
7261    if let Ok(rel) = resolved_out.strip_prefix(&project_root)
7262        && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
7263    {
7264        let dir = first.to_string();
7265        if !config.discovery.excluded_directories.contains(&dir) {
7266            config.discovery.excluded_directories.push(dir);
7267        }
7268    }
7269    if !config
7270        .discovery
7271        .excluded_directories
7272        .iter()
7273        .any(|d| d == "sloc")
7274    {
7275        config
7276            .discovery
7277            .excluded_directories
7278            .push("sloc".to_string());
7279    }
7280}
7281
7282/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
7283const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
7284    ScanSummarySnapshot {
7285        files_analyzed: run.summary_totals.files_analyzed,
7286        files_skipped: run.summary_totals.files_skipped,
7287        total_physical_lines: run.summary_totals.total_physical_lines,
7288        code_lines: run.summary_totals.code_lines,
7289        comment_lines: run.summary_totals.comment_lines,
7290        blank_lines: run.summary_totals.blank_lines,
7291        functions: run.summary_totals.functions,
7292        classes: run.summary_totals.classes,
7293        variables: run.summary_totals.variables,
7294        imports: run.summary_totals.imports,
7295        test_count: run.summary_totals.test_count,
7296        coverage_lines_found: run.summary_totals.coverage_lines_found,
7297        coverage_lines_hit: run.summary_totals.coverage_lines_hit,
7298        coverage_functions_found: run.summary_totals.coverage_functions_found,
7299        coverage_functions_hit: run.summary_totals.coverage_functions_hit,
7300        coverage_branches_found: run.summary_totals.coverage_branches_found,
7301        coverage_branches_hit: run.summary_totals.coverage_branches_hit,
7302    }
7303}
7304
7305/// Build the `RegistryEntry` for the just-completed scan run.
7306/// Extract the (os, host, user, ci) attribution tuple from a run's environment metadata,
7307/// normalising empty strings to `None`. Populated onto every `RegistryEntry` so pooled
7308/// reports from different environments/users can be told apart in the list/compare/trend views.
7309fn scan_env_fields(
7310    run: &AnalysisRun,
7311) -> (
7312    Option<String>,
7313    Option<String>,
7314    Option<String>,
7315    Option<String>,
7316) {
7317    let non_empty = |s: &str| (!s.is_empty()).then(|| s.to_owned());
7318    (
7319        non_empty(&run.environment.operating_system),
7320        non_empty(&run.environment.initiator_hostname),
7321        non_empty(&run.environment.initiator_username),
7322        run.environment.ci_name.clone().filter(|s| !s.is_empty()),
7323    )
7324}
7325
7326pub(crate) fn build_run_registry_entry(
7327    run: &AnalysisRun,
7328    run_id: &str,
7329    project_label: &str,
7330    artifacts: &RunArtifacts,
7331) -> RegistryEntry {
7332    let (scan_os, scan_host, scan_user, scan_ci) = scan_env_fields(run);
7333    RegistryEntry {
7334        run_id: run_id.to_owned(),
7335        timestamp_utc: run.tool.timestamp_utc,
7336        project_label: project_label.to_owned(),
7337        input_roots: run.input_roots.clone(),
7338        json_path: artifacts.json_path.clone(),
7339        html_path: artifacts.html_path.clone(),
7340        pdf_path: artifacts.pdf_path.clone(),
7341        csv_path: artifacts.csv_path.clone(),
7342        xlsx_path: artifacts.xlsx_path.clone(),
7343        summary: summary_snapshot_from_run(run),
7344        git_branch: run.git_branch.clone(),
7345        git_commit: run.git_commit_short.clone(),
7346        git_commit_long: run.git_commit_long.clone(),
7347        git_author: run.git_commit_author.clone(),
7348        git_tags: run.git_tags.clone(),
7349        git_nearest_tag: run.git_nearest_tag.clone(),
7350        git_commit_date: run.git_commit_date.clone(),
7351        scan_os,
7352        scan_host,
7353        scan_user,
7354        scan_ci,
7355    }
7356}
7357
7358/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
7359fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7360    if let Some(policy) = form.mixed_line_policy {
7361        config.analysis.mixed_line_policy = policy;
7362    }
7363    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
7364    config.analysis.generated_file_detection =
7365        form.generated_file_detection.as_deref() != Some("disabled");
7366    config.analysis.minified_file_detection =
7367        form.minified_file_detection.as_deref() != Some("disabled");
7368    config.analysis.vendor_directory_detection =
7369        form.vendor_directory_detection.as_deref() != Some("disabled");
7370    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
7371    if let Some(binary_behavior) = form.binary_file_behavior {
7372        config.analysis.binary_file_behavior = binary_behavior;
7373    }
7374    apply_report_opts(config, form);
7375    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
7376    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
7377    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
7378    if let Some(policy) = form.continuation_line_policy {
7379        config.analysis.continuation_line_policy = policy;
7380    }
7381    if let Some(policy) = form.blank_in_block_comment_policy {
7382        config.analysis.blank_in_block_comment_policy = policy;
7383    }
7384    config.analysis.count_compiler_directives =
7385        form.count_compiler_directives.as_deref() != Some("disabled");
7386    config.analysis.attribution = form.attribution.as_deref() != Some("disabled");
7387    apply_style_threshold(config, form);
7388    apply_coverage_path(config, form);
7389}
7390
7391fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7392    if let Some(report_title) = form.report_title.as_deref() {
7393        let trimmed = report_title.trim();
7394        if !trimmed.is_empty() {
7395            config.reporting.report_title = trimmed.to_string();
7396        }
7397    }
7398    if let Some(hf) = form.report_header_footer.as_deref() {
7399        let trimmed = hf.trim();
7400        config.reporting.report_header_footer = if trimmed.is_empty() {
7401            None
7402        } else {
7403            Some(trimmed.to_string())
7404        };
7405    }
7406}
7407
7408fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7409    apply_style_col_threshold(config, form);
7410    apply_style_analysis_enabled(config, form);
7411    apply_style_score_threshold(config, form);
7412    apply_style_lang_scope(config, form);
7413    apply_activity_window(config, form);
7414}
7415
7416fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7417    if let Some(threshold_str) = form.style_col_threshold.as_deref()
7418        && let Ok(t) = threshold_str.parse::<u16>()
7419        && (t == 80 || t == 100 || t == 120)
7420    {
7421        config.analysis.style_col_threshold = t;
7422    }
7423}
7424
7425fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7426    if let Some(v) = form.style_analysis_enabled.as_deref() {
7427        config.analysis.style_analysis_enabled = v != "disabled";
7428    }
7429}
7430
7431fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7432    if let Some(v) = form.style_score_threshold.as_deref()
7433        && let Ok(t) = v.parse::<u8>()
7434    {
7435        config.analysis.style_score_threshold = t.min(100);
7436    }
7437}
7438
7439fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7440    if let Some(v) = form.style_lang_scope.as_deref() {
7441        let scope = v.trim();
7442        if scope == "c_family" || scope == "all" {
7443            config.analysis.style_lang_scope = scope.to_string();
7444        }
7445    }
7446}
7447
7448fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7449    // Git hotspots window. On by default (config default 90). A parsed value overrides it —
7450    // including 0, which disables hotspots. A blank/unparseable field keeps the default.
7451    if let Some(w) = form.activity_window.as_deref() {
7452        let w = w.trim();
7453        if !w.is_empty()
7454            && let Ok(days) = w.parse::<u32>()
7455        {
7456            config.analysis.activity_window_days = Some(days);
7457        }
7458    }
7459}
7460
7461fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7462    if let Some(cov) = &form.coverage_file {
7463        let trimmed = cov.trim();
7464        if !trimmed.is_empty() {
7465            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
7466        }
7467    }
7468}
7469
7470/// Fire-and-forget: generate the PDF in a background task if one is pending.
7471/// On failure, clears `pdf_path` in the artifacts map so the results page shows
7472/// an error instead of spinning indefinitely.
7473/// Shared tail for the background PDF tasks: inspect the `spawn_blocking` join result and, on any
7474/// failure/panic, clear the run's `pdf_path` so the result page surfaces an error instead of
7475/// spinning. `label` distinguishes the log line ("background PDF" vs "on-demand PDF").
7476async fn finalize_pdf_task(
7477    result: std::result::Result<anyhow::Result<()>, tokio::task::JoinError>,
7478    label: &str,
7479    run_id: String,
7480    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
7481) {
7482    let failed = match result {
7483        Ok(Ok(())) => false,
7484        Ok(Err(err)) => {
7485            eprintln!("[oxide-sloc][pdf] {label} failed: {err}");
7486            true
7487        }
7488        Err(err) => {
7489            eprintln!("[oxide-sloc][pdf] {label} task panicked: {err}");
7490            true
7491        }
7492    };
7493    if failed {
7494        let mut map = artifacts.lock().await;
7495        if let Some(entry) = map.get_mut(&run_id) {
7496            entry.pdf_path = None;
7497        }
7498    }
7499}
7500
7501fn spawn_pdf_background(
7502    pending_pdf: PendingPdf,
7503    run_id: String,
7504    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
7505) {
7506    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
7507        tokio::spawn(async move {
7508            let result = tokio::task::spawn_blocking(move || {
7509                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
7510                if cleanup_src {
7511                    let _ = fs::remove_file(&pdf_src);
7512                }
7513                r
7514            })
7515            .await;
7516            finalize_pdf_task(result, "background PDF", run_id, artifacts).await;
7517        });
7518    }
7519}
7520
7521/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
7522/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
7523/// result page can show an error on the next visit instead of spinning indefinitely.
7524fn spawn_native_pdf_background(
7525    json_path: PathBuf,
7526    pdf_dest: PathBuf,
7527    run_id: String,
7528    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
7529) {
7530    tokio::spawn(async move {
7531        let result = tokio::task::spawn_blocking(move || {
7532            let run = sloc_core::read_json(&json_path)?;
7533            write_pdf_from_run(&run, &pdf_dest)
7534        })
7535        .await;
7536        finalize_pdf_task(result, "on-demand PDF", run_id, artifacts).await;
7537    });
7538}
7539
7540/// Sum the code lines added in this comparison (new + grown files).
7541fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7542    cmp.file_deltas
7543        .iter()
7544        .map(|f| match f.status {
7545            FileChangeStatus::Added => f.current_code,
7546            FileChangeStatus::Modified => f.code_delta.max(0),
7547            _ => 0,
7548        })
7549        .sum()
7550}
7551
7552/// Sum the code lines removed in this comparison (deleted + shrunk files).
7553fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7554    cmp.file_deltas
7555        .iter()
7556        .map(|f| match f.status {
7557            FileChangeStatus::Removed => f.baseline_code,
7558            FileChangeStatus::Modified => (-f.code_delta).max(0),
7559            _ => 0,
7560        })
7561        .sum()
7562}
7563
7564/// Sum the code lines present in both scans without any change (Unchanged files).
7565fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7566    cmp.file_deltas
7567        .iter()
7568        .filter(|f| f.status == FileChangeStatus::Unchanged)
7569        .map(|f| f.current_code)
7570        .sum()
7571}
7572
7573/// Sum the code lines residing in files that were modified between the two scans.
7574fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7575    cmp.file_deltas
7576        .iter()
7577        .filter(|f| f.status == FileChangeStatus::Modified)
7578        .map(|f| f.current_code)
7579        .sum()
7580}
7581
7582/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
7583fn build_submodule_row(
7584    s: &sloc_core::SubmoduleSummary,
7585    run: &AnalysisRun,
7586    run_id: &str,
7587    run_dir: &Path,
7588) -> SubmoduleRow {
7589    let safe = sanitize_project_label(&s.name);
7590    let artifact_key = format!("sub_{safe}");
7591    let pdf_artifact_key = format!("sub_{safe}_pdf");
7592    let html_url = if run.effective_configuration.discovery.submodule_breakdown {
7593        let parent_path = run
7594            .input_roots
7595            .first()
7596            .map_or("", std::string::String::as_str);
7597        let sub_run = build_sub_run(run, s, parent_path);
7598        let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
7599        render_sub_report_html(&sub_run, Some(&pdf_server_url))
7600            .ok()
7601            .and_then(|sub_html| {
7602                let sub_dir = sloc_core::reject_traversal(&run_dir.join("submodules")).ok()?;
7603                let _ = fs::create_dir_all(&sub_dir);
7604                let html_path = sub_dir.join(format!("{artifact_key}.html"));
7605                if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
7606                    // Pre-generate the sub-report PDF using the programmatic renderer
7607                    // so "View PDF" never needs to spawn Chrome for submodules.
7608                    let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
7609                    let _ = write_pdf_from_run(&sub_run, &pdf_path);
7610                    Some(format!("/runs/{artifact_key}/{run_id}"))
7611                } else {
7612                    None
7613                }
7614            })
7615    } else {
7616        None
7617    };
7618    SubmoduleRow {
7619        name: s.name.clone(),
7620        relative_path: s.relative_path.clone(),
7621        files_analyzed: s.files_analyzed,
7622        code_lines: s.code_lines,
7623        comment_lines: s.comment_lines,
7624        blank_lines: s.blank_lines,
7625        total_physical_lines: s.total_physical_lines,
7626        html_url,
7627    }
7628}
7629
7630// Immediately returns a wait page and runs the analysis in a background tokio task.
7631// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
7632#[allow(clippy::similar_names)]
7633#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
7634#[allow(clippy::too_many_lines)]
7635async fn analyze_handler(
7636    State(state): State<AppState>,
7637    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7638    Form(form): Form<AnalyzeForm>,
7639) -> impl IntoResponse {
7640    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
7641        let template = ErrorTemplate {
7642            message: format!(
7643                "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
7644             Please wait a moment and try again."
7645            ),
7646            last_report_url: None,
7647            last_report_label: None,
7648            run_id: None,
7649            error_code: Some(503),
7650            csp_nonce: csp_nonce.clone(),
7651            version: env!("CARGO_PKG_VERSION"),
7652        };
7653        return (
7654            StatusCode::SERVICE_UNAVAILABLE,
7655            Html(
7656                template
7657                    .render()
7658                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
7659            ),
7660        )
7661            .into_response();
7662    };
7663
7664    let mut config = state.base_config.clone();
7665
7666    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
7667    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
7668    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
7669
7670    if !is_git_mode {
7671        let resolved_path = resolve_input_path(&form.path);
7672        if state.server_mode
7673            && !is_upload_tmp_path(&resolved_path)
7674            && !is_sample_path(&resolved_path)
7675            && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
7676        {
7677            return resp;
7678        }
7679        config.discovery.root_paths = vec![resolved_path];
7680    }
7681
7682    apply_form_to_config(&mut config, &form);
7683    apply_output_dir_exclusions(
7684        &mut config,
7685        &form.path,
7686        form.output_dir.as_deref().unwrap_or(""),
7687    );
7688
7689    // Generate a wait_id now (before spawning) so the client can poll for status.
7690    let wait_id = uuid::Uuid::new_v4().to_string();
7691    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
7692
7693    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
7694    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
7695    let task_cancel = Arc::clone(&cancel_token);
7696
7697    // Phase tracker: updated by run_analysis_task at key checkpoints.
7698    let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
7699    let task_phase = Arc::clone(&phase);
7700
7701    let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7702    let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7703    let task_files_done = Arc::clone(&files_done);
7704    let task_files_total = Arc::clone(&files_total);
7705
7706    // Separate counters for the per-file `git blame` attribution pass, which runs after file
7707    // counting finishes and is the slowest stage on large repos. Surfacing them keeps the UI
7708    // moving instead of sitting frozen at "files done / files total".
7709    let attrib_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7710    let attrib_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7711    let task_attrib_done = Arc::clone(&attrib_done);
7712    let task_attrib_total = Arc::clone(&attrib_total);
7713
7714    // Register Running state before building the task struct so the semaphore permit
7715    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
7716    {
7717        let mut runs = state.async_runs.lock().await;
7718        runs.insert(
7719            wait_id.clone(),
7720            AsyncRunState::Running {
7721                started_at: std::time::Instant::now(),
7722                cancel_token,
7723                phase,
7724                files_done,
7725                files_total,
7726                attrib_done,
7727                attrib_total,
7728            },
7729        );
7730    }
7731
7732    let task = AnalysisTask {
7733        sem_permit,
7734        state: state.clone(),
7735        wait_id: wait_id.clone(),
7736        config,
7737        cancel: task_cancel,
7738        phase: task_phase,
7739        files_done: task_files_done,
7740        files_total: task_files_total,
7741        attrib_done: task_attrib_done,
7742        attrib_total: task_attrib_total,
7743        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
7744        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
7745        project_path: form.path.clone(),
7746        // In server mode the client-supplied output_dir is ignored — artifacts are
7747        // always written under the server's configured output root so remote users
7748        // cannot direct writes to arbitrary filesystem paths.
7749        output_dir: if state.server_mode {
7750            None
7751        } else {
7752            form.output_dir.clone()
7753        },
7754        clones_dir: state.git_clones_dir.clone(),
7755        cocomo_mode: form
7756            .cocomo_mode
7757            .clone()
7758            .unwrap_or_else(|| "organic".to_string()),
7759        complexity_alert: form
7760            .complexity_alert
7761            .as_deref()
7762            .and_then(|s| s.parse::<u32>().ok())
7763            .unwrap_or(0),
7764        exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
7765    };
7766
7767    tokio::spawn(run_analysis_task(task));
7768
7769    let template = ScanWaitTemplate {
7770        version: env!("CARGO_PKG_VERSION"),
7771        wait_id_json,
7772        project_path: form.path.clone(),
7773        csp_nonce,
7774    };
7775    let html = template
7776        .render()
7777        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
7778    let mut response = Html(html).into_response();
7779    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
7780        && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
7781    {
7782        response.headers_mut().insert(name, val);
7783    }
7784    response
7785}
7786
7787struct AnalysisTask {
7788    sem_permit: tokio::sync::OwnedSemaphorePermit,
7789    state: AppState,
7790    wait_id: String,
7791    config: AppConfig,
7792    cancel: Arc<std::sync::atomic::AtomicBool>,
7793    phase: Arc<std::sync::Mutex<String>>,
7794    files_done: Arc<std::sync::atomic::AtomicUsize>,
7795    files_total: Arc<std::sync::atomic::AtomicUsize>,
7796    attrib_done: Arc<std::sync::atomic::AtomicUsize>,
7797    attrib_total: Arc<std::sync::atomic::AtomicUsize>,
7798    git_repo: Option<String>,
7799    git_ref: Option<String>,
7800    project_path: String,
7801    output_dir: Option<String>,
7802    clones_dir: PathBuf,
7803    cocomo_mode: String,
7804    complexity_alert: u32,
7805    exclude_duplicates: bool,
7806}
7807
7808#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
7809async fn run_analysis_task(task: AnalysisTask) {
7810    let _permit = task.sem_permit;
7811
7812    let cancel_sb = Arc::clone(&task.cancel);
7813    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
7814    let clones_dir_sb = task.clones_dir;
7815    // Save the upload staging path before config is moved into spawn_blocking.
7816    let upload_staging_root = task
7817        .config
7818        .discovery
7819        .root_paths
7820        .first()
7821        .filter(|p| is_upload_tmp_path(p))
7822        .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
7823        .map(PathBuf::from);
7824    let config_sb = task.config;
7825    let progress_sb = sloc_core::ProgressCounters {
7826        files_done: Arc::clone(&task.files_done),
7827        files_total: Arc::clone(&task.files_total),
7828        // Share the phase handle so core can flip the label to "Attributing authorship" when it
7829        // enters the blame pass — the web layer only sets "Scanning files"/"Writing reports".
7830        phase: Some(Arc::clone(&task.phase)),
7831        attrib_done: Arc::clone(&task.attrib_done),
7832        attrib_total: Arc::clone(&task.attrib_total),
7833    };
7834    if let Ok(mut p) = task.phase.lock() {
7835        *p = "Scanning files".to_string();
7836    }
7837    let analysis_result = tokio::task::spawn_blocking(move || {
7838        run_analysis_blocking(
7839            config_sb,
7840            git_repo_sb,
7841            git_ref_sb,
7842            clones_dir_sb,
7843            cancel_sb,
7844            Some(progress_sb),
7845        )
7846    })
7847    .await
7848    .map_err(|err| anyhow::anyhow!(err.to_string()))
7849    .and_then(|result| result);
7850
7851    if let Ok(mut p) = task.phase.lock() {
7852        *p = "Writing reports".to_string();
7853    }
7854
7855    // If cancelled while running, discard results and mark as cancelled.
7856    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
7857        let mut runs = task.state.async_runs.lock().await;
7858        // Only overwrite if still Running (don't clobber a Complete that snuck in).
7859        if matches!(
7860            runs.get(&task.wait_id),
7861            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
7862        ) {
7863            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
7864        }
7865        drop(runs);
7866        return;
7867    }
7868
7869    let run = match analysis_result {
7870        Ok(v) => v,
7871        Err(err) => {
7872            // Distinguish user-cancelled from real failure.
7873            if err.to_string().contains("analysis cancelled") {
7874                let mut runs = task.state.async_runs.lock().await;
7875                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
7876                drop(runs);
7877                return;
7878            }
7879            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
7880            let mut runs = task.state.async_runs.lock().await;
7881            runs.insert(
7882                task.wait_id.clone(),
7883                AsyncRunState::Failed {
7884                    message: "Analysis failed. Check that the path exists and is readable."
7885                        .to_string(),
7886                },
7887            );
7888            drop(runs);
7889            return;
7890        }
7891    };
7892
7893    let run_id = run.tool.run_id.clone();
7894    tracing::info!(event = "scan_complete", run_id = %run_id,
7895        path = %task.project_path, files = run.summary_totals.files_analyzed,
7896        "Analysis finished");
7897
7898    let prev_entry: Option<RegistryEntry> = {
7899        let reg = task.state.registry.lock().await;
7900        reg.entries_for_roots(&run.input_roots)
7901            .into_iter()
7902            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
7903            .cloned()
7904    };
7905
7906    let scan_delta = prev_entry.as_ref().and_then(|prev| {
7907        prev.json_path
7908            .as_ref()
7909            .and_then(|p| read_json(p).ok())
7910            .map(|prev_run| compute_delta(&prev_run, &run))
7911    });
7912    let prev_scan_count: usize = {
7913        let reg = task.state.registry.lock().await;
7914        reg.entries_for_roots(&run.input_roots)
7915            .iter()
7916            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
7917            .count()
7918    };
7919
7920    // Build the HTML report now that delta is available, so the artifact
7921    // embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
7922    let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
7923        .as_ref()
7924        .zip(prev_entry.as_ref())
7925        .map(|(cmp, prev)| ReportDeltaContext {
7926            delta_code_added: sum_added_code_lines(cmp),
7927            delta_code_removed: sum_removed_code_lines(cmp),
7928            delta_unmodified_lines: sum_unmodified_code_lines(cmp),
7929            delta_files_added: cmp.files_added,
7930            delta_files_removed: cmp.files_removed,
7931            delta_files_modified: cmp.files_modified,
7932            delta_files_unchanged: cmp.files_unchanged,
7933            prev_code_lines: prev.summary.code_lines,
7934            prev_scan_count: prev_scan_count + 1,
7935            prev_scan_label: fmt_la_time(prev.timestamp_utc),
7936            prev_run_id: Some(prev.run_id.clone()),
7937            current_run_id: Some(run_id.clone()),
7938        });
7939    let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
7940        Ok(h) => h,
7941        Err(err) => {
7942            eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
7943            let mut runs = task.state.async_runs.lock().await;
7944            runs.insert(
7945                task.wait_id.clone(),
7946                AsyncRunState::Failed {
7947                    message: "Failed to render HTML report.".to_string(),
7948                },
7949            );
7950            drop(runs);
7951            return;
7952        }
7953    };
7954
7955    let output_root = resolve_output_root(task.output_dir.as_deref());
7956    let project_label = derive_project_label(
7957        task.git_repo.as_deref(),
7958        task.git_ref.as_deref(),
7959        &task.project_path,
7960    );
7961    // For git-remote scans the ref is already in `project_label`; for local-path scans fold in the
7962    // checked-out branch so the folder name is self-describing.
7963    let dir_branch = if task.git_repo.as_deref().is_some_and(|s| !s.is_empty()) {
7964        None
7965    } else {
7966        run.git_branch.as_deref()
7967    };
7968    let run_dir = output_root.join(derive_run_dir_name(&project_label, dir_branch, &run_id));
7969    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
7970
7971    let result_context = RunResultContext {
7972        prev_entry: prev_entry.clone(),
7973        prev_scan_count,
7974        project_path: task.project_path.clone(),
7975        cocomo_mode: task.cocomo_mode.clone(),
7976        complexity_alert: task.complexity_alert,
7977        exclude_duplicates: task.exclude_duplicates,
7978    };
7979
7980    let artifact_result = persist_run_artifacts(
7981        &run,
7982        &report_html,
7983        &run_dir,
7984        &run.effective_configuration.reporting.report_title,
7985        &file_stem,
7986        result_context,
7987    );
7988
7989    let (artifacts, pending_pdf) = match artifact_result {
7990        Ok(v) => v,
7991        Err(err) => {
7992            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
7993            let mut runs = task.state.async_runs.lock().await;
7994            runs.insert(
7995                task.wait_id.clone(),
7996                AsyncRunState::Failed {
7997                    message: "Failed to save report artifacts. Check available disk space."
7998                        .to_string(),
7999                },
8000            );
8001            drop(runs);
8002            return;
8003        }
8004    };
8005
8006    {
8007        let mut map = task.state.artifacts.lock().await;
8008        map.insert(run_id.clone(), artifacts.clone());
8009    }
8010
8011    {
8012        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
8013        let mut reg = task.state.registry.lock().await;
8014        reg.add_entry(entry);
8015        let _ = reg.save(&task.state.registry_path);
8016    }
8017
8018    if let Some(ref cfg_path) = artifacts.scan_config_path {
8019        save_scan_config_json(
8020            cfg_path,
8021            &run,
8022            &task.project_path,
8023            task.output_dir.as_deref(),
8024            &task.cocomo_mode,
8025            task.complexity_alert,
8026            task.exclude_duplicates,
8027        );
8028    }
8029
8030    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
8031
8032    prom_runs_total().inc();
8033
8034    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
8035    let mut runs = task.state.async_runs.lock().await;
8036    runs.insert(
8037        task.wait_id.clone(),
8038        AsyncRunState::Complete {
8039            run_id: run_id.clone(),
8040        },
8041    );
8042    drop(runs);
8043
8044    // Remove the client-upload staging directory after a successful scan so
8045    // that uploaded project files don't accumulate in the OS temp directory.
8046    if let Some(staging) = upload_staging_root {
8047        let _ = tokio::fs::remove_dir_all(staging).await;
8048    }
8049
8050    let _ = scan_delta;
8051}
8052
8053fn save_scan_config_json(
8054    cfg_path: &std::path::Path,
8055    run: &sloc_core::AnalysisRun,
8056    project_path: &str,
8057    output_dir: Option<&str>,
8058    cocomo_mode: &str,
8059    complexity_alert: u32,
8060    exclude_duplicates: bool,
8061) {
8062    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
8063        .ok()
8064        .and_then(|v| v.as_str().map(String::from))
8065        .unwrap_or_else(|| "code_only".to_string());
8066    let behavior_str =
8067        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
8068            .ok()
8069            .and_then(|v| v.as_str().map(String::from))
8070            .unwrap_or_else(|| "skip".to_string());
8071    let continuation_policy_str = serde_json::to_value(
8072        run.effective_configuration
8073            .analysis
8074            .continuation_line_policy,
8075    )
8076    .ok()
8077    .and_then(|v| v.as_str().map(String::from))
8078    .unwrap_or_else(default_each_physical_line);
8079    let blank_policy_str = serde_json::to_value(
8080        run.effective_configuration
8081            .analysis
8082            .blank_in_block_comment_policy,
8083    )
8084    .ok()
8085    .and_then(|v| v.as_str().map(String::from))
8086    .unwrap_or_else(default_count_as_comment);
8087    let scan_cfg = ScanConfig {
8088        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
8089        path: project_path.to_string(),
8090        include_globs: run
8091            .effective_configuration
8092            .discovery
8093            .include_globs
8094            .join("\n"),
8095        exclude_globs: run
8096            .effective_configuration
8097            .discovery
8098            .exclude_globs
8099            .join("\n"),
8100        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
8101        mixed_line_policy: policy_str,
8102        python_docstrings_as_comments: run
8103            .effective_configuration
8104            .analysis
8105            .python_docstrings_as_comments,
8106        generated_file_detection: run
8107            .effective_configuration
8108            .analysis
8109            .generated_file_detection,
8110        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
8111        vendor_directory_detection: run
8112            .effective_configuration
8113            .analysis
8114            .vendor_directory_detection,
8115        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
8116        binary_file_behavior: behavior_str,
8117        output_dir: output_dir.unwrap_or("").to_string(),
8118        report_title: run.effective_configuration.reporting.report_title.clone(),
8119        continuation_line_policy: continuation_policy_str,
8120        blank_in_block_comment_policy: blank_policy_str,
8121        count_compiler_directives: run
8122            .effective_configuration
8123            .analysis
8124            .count_compiler_directives,
8125        style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
8126        style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
8127        style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
8128        style_lang_scope: run
8129            .effective_configuration
8130            .analysis
8131            .style_lang_scope
8132            .clone(),
8133        coverage_file: run
8134            .effective_configuration
8135            .analysis
8136            .coverage_file
8137            .as_ref()
8138            .map(|p| p.display().to_string())
8139            .unwrap_or_default(),
8140        cocomo_mode: cocomo_mode.to_string(),
8141        complexity_alert,
8142        exclude_duplicates,
8143        activity_window: run
8144            .effective_configuration
8145            .analysis
8146            .activity_window_days
8147            .unwrap_or(0),
8148        attribution: run.effective_configuration.analysis.attribution,
8149    };
8150    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
8151        let _ = std::fs::write(cfg_path, json);
8152    }
8153}
8154
8155#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
8156fn run_analysis_blocking(
8157    mut config: AppConfig,
8158    git_repo: Option<String>,
8159    git_ref: Option<String>,
8160    clones_dir: PathBuf,
8161    cancel: Arc<std::sync::atomic::AtomicBool>,
8162    progress: Option<sloc_core::ProgressCounters>,
8163) -> Result<sloc_core::AnalysisRun> {
8164    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
8165        let dest = git_clone_dest(&repo, &clones_dir);
8166        sloc_git::clone_or_fetch(&repo, &dest)?;
8167        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
8168        sloc_git::create_worktree(&dest, &refname, &wt)?;
8169        config.discovery.root_paths = vec![wt.clone()];
8170        let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
8171        let _ = sloc_git::destroy_worktree(&dest, &wt);
8172        let mut run = run?;
8173        if run.git_branch.is_none() {
8174            run.git_branch = Some(refname);
8175        }
8176        return Ok(run);
8177    }
8178    analyze(&config, "serve", Some(&cancel), progress.as_ref())
8179}
8180
8181fn derive_project_label(
8182    git_repo: Option<&str>,
8183    git_ref: Option<&str>,
8184    fallback_path: &str,
8185) -> String {
8186    match (
8187        git_repo.filter(|s| !s.is_empty()),
8188        git_ref.filter(|s| !s.is_empty()),
8189    ) {
8190        (Some(repo), Some(refname)) => {
8191            let repo_name = repo
8192                .trim_end_matches('/')
8193                .trim_end_matches(".git")
8194                .rsplit('/')
8195                .next()
8196                .unwrap_or("repo");
8197            sanitize_project_label(&format!("{repo_name}_{refname}"))
8198        }
8199        _ => sanitize_project_label(fallback_path),
8200    }
8201}
8202
8203/// Build a compact on-disk run-directory name: `<project>[-<branch>]-<date>-<time>-<short-uuid>`.
8204///
8205/// The `run_id` (`YYYYMMDD-HHMM-<32-hex-uuid>`) keeps a folder unique but its full UUID makes the
8206/// name long and unreadable, so this trims the UUID to 8 chars and folds the branch in after the
8207/// project name. The full `run_id` still lives inside the run JSON, which is what every lookup
8208/// matches on, so shortening the folder name is safe. `branch` is skipped when empty or already
8209/// reflected in `project_label` (git-remote scans encode the ref there).
8210fn derive_run_dir_name(project_label: &str, branch: Option<&str>, run_id: &str) -> String {
8211    // Trim the 32-hex UUID tail to 8 chars while preserving the "date-time" prefix.
8212    let short_run = match run_id.rsplit_once('-') {
8213        Some((stamp, uuid)) if uuid.len() > 8 && uuid.chars().all(|c| c.is_ascii_hexdigit()) => {
8214            format!("{stamp}-{}", &uuid[..8])
8215        }
8216        _ => run_id.to_string(),
8217    };
8218    let branch_part = branch
8219        .map(str::trim)
8220        .filter(|b| !b.is_empty())
8221        .map(|b| format!("-{}", sanitize_project_label(b)))
8222        .filter(|part| !project_label.contains(part.trim_start_matches('-')))
8223        .unwrap_or_default();
8224    format!("{project_label}{branch_part}-{short_run}")
8225}
8226
8227fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
8228    let commit = commit_short.unwrap_or("").trim();
8229    if commit.is_empty() {
8230        project_label.to_string()
8231    } else {
8232        format!("{project_label}_{commit}")
8233    }
8234}
8235
8236// ── Async scan status + result handlers ──────────────────────────────────────
8237
8238#[derive(Serialize)]
8239#[serde(tag = "state", rename_all = "snake_case")]
8240enum AsyncRunStatusResponse {
8241    Running {
8242        elapsed_secs: u64,
8243        phase: String,
8244        files_done: u64,
8245        files_total: u64,
8246        attrib_done: u64,
8247        attrib_total: u64,
8248    },
8249    Complete {
8250        run_id: String,
8251    },
8252    Failed {
8253        message: String,
8254    },
8255    Cancelled,
8256}
8257
8258async fn async_run_status_handler(
8259    State(state): State<AppState>,
8260    AxumPath(wait_id): AxumPath<String>,
8261) -> Response {
8262    // wait_id comes from our own UUID generator; reject any structurally malformed value.
8263    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
8264        return error::bad_request("invalid wait_id");
8265    }
8266    let run_state = {
8267        let runs = state.async_runs.lock().await;
8268        runs.get(&wait_id).cloned()
8269    };
8270    match run_state {
8271        None => error::not_found("run not found"),
8272        Some(AsyncRunState::Running {
8273            started_at,
8274            phase,
8275            files_done,
8276            files_total,
8277            attrib_done,
8278            attrib_total,
8279            ..
8280        }) => {
8281            // Treat runs older than 2 h as timed out (analysis should finish well under that).
8282            if started_at.elapsed() > std::time::Duration::from_hours(2) {
8283                let mut runs = state.async_runs.lock().await;
8284                runs.insert(
8285                    wait_id,
8286                    AsyncRunState::Failed {
8287                        message: "Analysis timed out after 2 hours.".to_string(),
8288                    },
8289                );
8290                drop(runs);
8291                return Json(AsyncRunStatusResponse::Failed {
8292                    message: "Analysis timed out after 2 hours.".to_string(),
8293                })
8294                .into_response();
8295            }
8296            let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
8297            Json(AsyncRunStatusResponse::Running {
8298                elapsed_secs: started_at.elapsed().as_secs(),
8299                phase: phase_str,
8300                files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
8301                files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
8302                attrib_done: attrib_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
8303                attrib_total: attrib_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
8304            })
8305            .into_response()
8306        }
8307        Some(AsyncRunState::Complete { run_id }) => {
8308            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
8309        }
8310        Some(AsyncRunState::Failed { message }) => {
8311            Json(AsyncRunStatusResponse::Failed { message }).into_response()
8312        }
8313        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
8314    }
8315}
8316
8317async fn cancel_run_handler(
8318    State(state): State<AppState>,
8319    AxumPath(wait_id): AxumPath<String>,
8320) -> Response {
8321    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
8322        return error::bad_request("invalid wait_id");
8323    }
8324    let mut runs = state.async_runs.lock().await;
8325    let resp = match runs.get(&wait_id) {
8326        Some(AsyncRunState::Running { cancel_token, .. }) => {
8327            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
8328            runs.insert(wait_id, AsyncRunState::Cancelled);
8329            StatusCode::OK.into_response()
8330        }
8331        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
8332        _ => error::not_found("run not found"),
8333    };
8334    drop(runs);
8335    resp
8336}
8337
8338async fn async_run_result_handler(
8339    State(state): State<AppState>,
8340    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8341    AxumPath(run_id): AxumPath<String>,
8342) -> Response {
8343    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
8344        return StatusCode::BAD_REQUEST.into_response();
8345    }
8346
8347    let artifacts = {
8348        let map = state.artifacts.lock().await;
8349        map.get(&run_id).cloned()
8350    };
8351    let artifacts = if let Some(a) = artifacts {
8352        a
8353    } else {
8354        let reg = state.registry.lock().await;
8355        if let Some(entry) = reg.find_by_run_id(&run_id) {
8356            recover_artifacts_from_registry(entry)
8357        } else {
8358            let html = ErrorTemplate {
8359                message: format!(
8360                    "Report not found. Run ID {} is not in the scan history.",
8361                    &run_id[..run_id.len().min(8)]
8362                ),
8363                last_report_url: Some("/view-reports".to_string()),
8364                last_report_label: Some("View Reports".to_string()),
8365                run_id: Some(run_id.clone()),
8366                error_code: Some(404),
8367                csp_nonce: csp_nonce.clone(),
8368                version: env!("CARGO_PKG_VERSION"),
8369            }
8370            .render()
8371            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
8372            return (StatusCode::NOT_FOUND, Html(html)).into_response();
8373        }
8374    };
8375
8376    let json_path = if let Some(p) = &artifacts.json_path {
8377        p.clone()
8378    } else {
8379        let html = ErrorTemplate {
8380            message: "JSON result was not saved for this run.".to_string(),
8381            last_report_url: Some("/view-reports".to_string()),
8382            last_report_label: Some("View Reports".to_string()),
8383            run_id: Some(run_id.clone()),
8384            error_code: Some(404),
8385            csp_nonce: csp_nonce.clone(),
8386            version: env!("CARGO_PKG_VERSION"),
8387        }
8388        .render()
8389        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
8390        return (StatusCode::NOT_FOUND, Html(html)).into_response();
8391    };
8392
8393    let Ok(mut run) = read_json(&json_path) else {
8394        let folder_hint = output_folder_hint(&json_path);
8395        let redirect_url = format!("/runs/result/{run_id}");
8396        return missing_scan_relocate_response(
8397            &format!(
8398                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
8399                 deleted. Browse to the folder containing your scan output to reconnect it.",
8400                json_path.display()
8401            ),
8402            &run_id,
8403            &folder_hint,
8404            &redirect_url,
8405            state.server_mode,
8406            &csp_nonce,
8407        );
8408    };
8409
8410    // Fold GitHub no-reply aliases (covers scans that predate the scan-time auto-merge) and apply
8411    // any operator-defined identity merges, so this report's Code Ownership panel matches the
8412    // dedicated /code-ownership page.
8413    auto_merge_noreply_identities(&mut run);
8414    let merge_map = IdentityMap::load(&identities_path(&state));
8415    apply_identity_map(&mut run, &merge_map);
8416
8417    let confluence_configured = {
8418        let store = state.confluence.lock().await;
8419        store.is_configured()
8420    };
8421
8422    render_result_page(
8423        &run,
8424        &artifacts,
8425        &run_id,
8426        &csp_nonce,
8427        confluence_configured,
8428        state.server_mode,
8429        &merge_map.groups,
8430    )
8431}
8432
8433/// Escape backslashes and double quotes for embedding a value inside a JSON string literal.
8434fn json_escape(s: &str) -> String {
8435    s.replace('\\', "\\\\").replace('"', "\\\"")
8436}
8437
8438/// Per-language line/symbol totals summed across every language in a run.
8439struct LangTotals {
8440    physical_lines: u64,
8441    code_lines: u64,
8442    comment_lines: u64,
8443    blank_lines: u64,
8444    mixed_lines: u64,
8445    functions: u64,
8446    classes: u64,
8447    variables: u64,
8448    imports: u64,
8449}
8450
8451fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
8452    let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
8453        run.totals_by_language.iter().map(f).sum()
8454    };
8455    LangTotals {
8456        physical_lines: s(|r| r.total_physical_lines),
8457        code_lines: s(|r| r.code_lines),
8458        comment_lines: s(|r| r.comment_lines),
8459        blank_lines: s(|r| r.blank_lines),
8460        mixed_lines: s(|r| r.mixed_lines_separate),
8461        functions: s(|r| r.functions),
8462        classes: s(|r| r.classes),
8463        variables: s(|r| r.variables),
8464        imports: s(|r| r.imports),
8465    }
8466}
8467
8468/// Previous-scan baseline strings and per-metric deltas shared by the live and offline pages.
8469struct DeltaFields {
8470    prev_fa_str: String,
8471    prev_fs_str: String,
8472    prev_pl_str: String,
8473    prev_cl_str: String,
8474    prev_cml_str: String,
8475    prev_bl_str: String,
8476    delta_fa_str: String,
8477    delta_fa_class: String,
8478    delta_fs_str: String,
8479    delta_fs_class: String,
8480    delta_pl_str: String,
8481    delta_pl_class: String,
8482    delta_cl_str: String,
8483    delta_cl_class: String,
8484    delta_cml_str: String,
8485    delta_cml_class: String,
8486    delta_bl_str: String,
8487    delta_bl_class: String,
8488    delta_lines_added: Option<i64>,
8489    delta_lines_removed: Option<i64>,
8490    delta_lines_net_str: String,
8491    delta_lines_net_class: String,
8492}
8493
8494// The delta_* locals deliberately mirror the `DeltaFields` struct field names (fa/fs/pl/cl/
8495// cml/bl = files-analyzed/skipped, physical/code/comment/blank lines) which are consumed by
8496// name in the Askama templates; renaming the locals to satisfy `similar_names` would diverge
8497// from those field names and obscure the 1:1 mapping.
8498#[allow(
8499    clippy::similar_names,
8500    reason = "locals mirror template-bound struct fields"
8501)]
8502fn compute_delta_fields(
8503    prev_entry: Option<&RegistryEntry>,
8504    totals: &LangTotals,
8505    files_analyzed: u64,
8506    files_skipped: u64,
8507    scan_delta: Option<&sloc_core::ScanComparison>,
8508) -> DeltaFields {
8509    let prev_sum = prev_entry.map(|e| &e.summary);
8510    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
8511
8512    let (delta_fa_str, delta_fa_class) =
8513        summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
8514    let (delta_fs_str, delta_fs_class) =
8515        summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
8516    let (delta_pl_str, delta_pl_class) = summary_delta(
8517        totals.physical_lines,
8518        prev_sum.map(|s| s.total_physical_lines),
8519    );
8520    let (delta_cl_str, delta_cl_class) =
8521        summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
8522    let (delta_cml_str, delta_cml_class) =
8523        summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
8524    let (delta_bl_str, delta_bl_class) =
8525        summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
8526
8527    let delta_lines_added = scan_delta.map(sum_added_code_lines);
8528    let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
8529    let (delta_lines_net_str, delta_lines_net_class) =
8530        match (delta_lines_added, delta_lines_removed) {
8531            (Some(a), Some(r)) => {
8532                let net = a - r;
8533                (fmt_delta(net), delta_class(net).to_string())
8534            }
8535            _ => ("\u{2014}".to_string(), "na".to_string()),
8536        };
8537
8538    DeltaFields {
8539        prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
8540        prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
8541        prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
8542        prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
8543        prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
8544        prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
8545        delta_fa_str,
8546        delta_fa_class: delta_fa_class.to_string(),
8547        delta_fs_str,
8548        delta_fs_class: delta_fs_class.to_string(),
8549        delta_pl_str,
8550        delta_pl_class: delta_pl_class.to_string(),
8551        delta_cl_str,
8552        delta_cl_class: delta_cl_class.to_string(),
8553        delta_cml_str,
8554        delta_cml_class: delta_cml_class.to_string(),
8555        delta_bl_str,
8556        delta_bl_class: delta_bl_class.to_string(),
8557        delta_lines_added,
8558        delta_lines_removed,
8559        delta_lines_net_str,
8560        delta_lines_net_class,
8561    }
8562}
8563
8564/// Count of unchanged code lines in a scan comparison.
8565fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
8566    scan_delta
8567        .file_deltas
8568        .iter()
8569        .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
8570        .map(|f| {
8571            #[allow(clippy::cast_sign_loss)]
8572            let n = f.current_code as u64;
8573            n
8574        })
8575        .sum()
8576}
8577
8578fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
8579    run.git_remote_url
8580        .as_deref()
8581        .zip(run.git_commit_long.as_deref())
8582        .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
8583}
8584
8585fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
8586    run.git_remote_url
8587        .as_deref()
8588        .zip(run.git_branch.as_deref())
8589        .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
8590}
8591
8592fn scan_performed_by(run: &AnalysisRun) -> String {
8593    run.environment.ci_name.clone().unwrap_or_else(|| {
8594        format!(
8595            "{} / {}",
8596            run.environment.initiator_username, run.environment.initiator_hostname
8597        )
8598    })
8599}
8600
8601/// Top-12 languages (by code lines) as a JSON array for the language bar chart.
8602fn build_lang_chart_json(run: &AnalysisRun) -> String {
8603    let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
8604    langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
8605    let entries: Vec<String> = langs
8606        .into_iter()
8607        .take(12)
8608        .map(|l| {
8609            let name = json_escape(l.language.display_name());
8610            format!(
8611                r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
8612                name,
8613                l.code_lines,
8614                l.comment_lines,
8615                l.blank_lines,
8616                l.total_physical_lines,
8617                l.functions,
8618                l.classes,
8619                l.variables,
8620                l.imports,
8621                l.files,
8622            )
8623        })
8624        .collect();
8625    format!("[{}]", entries.join(","))
8626}
8627
8628/// Per-language files-vs-lines points as a JSON array for the scatter chart.
8629fn build_scatter_chart_json(run: &AnalysisRun) -> String {
8630    let entries: Vec<String> = run
8631        .totals_by_language
8632        .iter()
8633        .map(|l| {
8634            let name = json_escape(l.language.display_name());
8635            format!(
8636                r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
8637                name, l.files, l.code_lines, l.total_physical_lines,
8638            )
8639        })
8640        .collect();
8641    format!("[{}]", entries.join(","))
8642}
8643
8644/// Per-language semantic-symbol counts as a JSON array for the semantic chart.
8645fn build_semantic_chart_json(run: &AnalysisRun) -> String {
8646    let entries: Vec<String> = run
8647        .totals_by_language
8648        .iter()
8649        .filter(|l| {
8650            l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
8651        })
8652        .map(|l| {
8653            let name = json_escape(l.language.display_name());
8654            format!(
8655                r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
8656                name, l.functions, l.classes, l.variables, l.imports, l.test_count,
8657            )
8658        })
8659        .collect();
8660    format!("[{}]", entries.join(","))
8661}
8662
8663/// Per-submodule line counts as a JSON array for the submodule chart.
8664fn build_submodule_chart_json(run: &AnalysisRun) -> String {
8665    let entries: Vec<String> = run
8666        .submodule_summaries
8667        .iter()
8668        .map(|s| {
8669            let name = json_escape(&s.name);
8670            format!(
8671                r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
8672                name,
8673                s.code_lines,
8674                s.comment_lines,
8675                s.blank_lines,
8676                s.total_physical_lines,
8677                s.files_analyzed,
8678            )
8679        })
8680        .collect();
8681    format!("[{}]", entries.join(","))
8682}
8683
8684/// `hit / found` as a one-decimal percentage string, or empty when nothing was found.
8685#[allow(clippy::cast_precision_loss)]
8686fn cov_pct_str(hit: u64, found: u64) -> String {
8687    if found > 0 {
8688        format!("{:.1}", hit as f64 / found as f64 * 100.0)
8689    } else {
8690        String::new()
8691    }
8692}
8693
8694/// `hit / found` summary string, or empty when nothing was found.
8695fn cov_lines_summary_str(hit: u64, found: u64) -> String {
8696    if found > 0 {
8697        format!("{hit} / {found}")
8698    } else {
8699        String::new()
8700    }
8701}
8702
8703const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
8704    use sloc_core::CocomoMode;
8705    match mode {
8706        CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
8707        CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
8708        CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
8709    }
8710}
8711
8712const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
8713    use sloc_core::CocomoMode;
8714    match mode {
8715        CocomoMode::Organic => "Organic",
8716        CocomoMode::SemiDetached => "Semi-detached",
8717        CocomoMode::Embedded => "Embedded",
8718    }
8719}
8720
8721const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
8722    use sloc_core::CocomoMode;
8723    match mode {
8724        CocomoMode::Organic => {
8725            "Organic: A small team working on a well-understood project in a familiar \
8726             environment with minimal external constraints. Suited for internal tools, \
8727             utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
8728        }
8729        CocomoMode::SemiDetached => {
8730            "Semi-detached: A mixed team with varying experience tackling a project with \
8731             moderate novelty and some rigid constraints. Typical for compilers, transaction \
8732             systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
8733        }
8734        CocomoMode::Embedded => {
8735            "Embedded: Tight hardware, software, or operational constraints requiring \
8736             significant innovation and deep integration work. Typical for real-time control \
8737             systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
8738        }
8739    }
8740}
8741
8742/// COCOMO display strings recomputed for the scan-wizard-selected mode.
8743struct CocomoFields {
8744    has_cocomo: bool,
8745    effort_str: String,
8746    duration_str: String,
8747    staff_str: String,
8748    ksloc_str: String,
8749    mode_label: String,
8750    mode_tooltip: String,
8751}
8752
8753#[allow(clippy::cast_precision_loss)]
8754fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
8755    use sloc_core::CocomoMode;
8756    let mode = match mode_str {
8757        "semi_detached" => CocomoMode::SemiDetached,
8758        "embedded" => CocomoMode::Embedded,
8759        _ => CocomoMode::Organic,
8760    };
8761    let (a, b, c, d) = cocomo_coefficients(mode);
8762    let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
8763    let effort = a * ksloc.powf(b);
8764    let duration = c * effort.powf(d);
8765    let staff = if duration > 0.0 {
8766        effort / duration
8767    } else {
8768        0.0
8769    };
8770    let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
8771    let mode_label = cocomo_mode_label(mode).to_string();
8772    let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
8773    if run.summary_totals.code_lines > 0 {
8774        CocomoFields {
8775            has_cocomo: true,
8776            effort_str: round2(effort),
8777            duration_str: round2(duration),
8778            staff_str: round2(staff),
8779            ksloc_str: round2(ksloc),
8780            mode_label,
8781            mode_tooltip,
8782        }
8783    } else {
8784        CocomoFields {
8785            has_cocomo: false,
8786            effort_str: String::new(),
8787            duration_str: String::new(),
8788            staff_str: String::new(),
8789            ksloc_str: String::new(),
8790            mode_label,
8791            mode_tooltip,
8792        }
8793    }
8794}
8795
8796#[allow(clippy::too_many_lines)]
8797#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
8798#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
8799/// Render the Code Ownership panel for the run-result page: the per-contributor table plus the
8800/// shared "Combine contributors" merge panel. Returns an empty string when the run carries no
8801/// blame attribution (attribution disabled, or a non-git path), so the section simply doesn't
8802/// appear. Merges post to the shared `/api/ownership/merge` endpoint and redirect back to this
8803/// run's result page via `redirect_to`.
8804fn render_result_ownership_section(
8805    run: &AnalysisRun,
8806    merge_groups: &[AuthorMergeGroup],
8807    redirect_to: &str,
8808) -> String {
8809    use std::fmt::Write as _;
8810    let total_code: u64 = run.authors.iter().map(|a| a.counts.code_lines).sum();
8811    let rows = build_ownership_rows(Some(run), total_code);
8812    if rows.is_empty() {
8813        return String::new();
8814    }
8815    let name_link = |r: &OwnershipRow| -> String {
8816        let name = own_esc(&r.name);
8817        match r.profile.as_deref() {
8818            Some(url) => format!(
8819                r#"<a class="author-link" href="{url}" target="_blank" rel="noopener" title="View {name}'s profile / contributions">{name}</a>"#,
8820                url = own_esc(url),
8821                name = name,
8822            ),
8823            None => name,
8824        }
8825    };
8826    let mut author_table = String::new();
8827    for r in &rows {
8828        let _ = write!(
8829            author_table,
8830            r#"<tr><td><span class="own-dot" data-sx-style="background:{color};"></span>{name}</td><td><span class="own-email">{email}</span></td><td class="num">{code}</td><td class="num">{comment}</td><td class="num">{blank}</td><td class="num">{total}</td><td class="num own-pct">{pct:.1}%</td><td class="num">{files}</td></tr>"#,
8831            color = r.color,
8832            name = name_link(r),
8833            email = own_esc(&r.email),
8834            code = fmt_num(r.code as i64),
8835            comment = fmt_num(r.comment as i64),
8836            blank = fmt_num(r.blank as i64),
8837            total = fmt_num(r.total as i64),
8838            pct = r.code_pct,
8839            files = r.files_owned,
8840        );
8841    }
8842    let merge_panel = render_merge_panel(merge_groups, &rows, redirect_to);
8843    let plural = if rows.len() == 1 { "" } else { "s" };
8844    format!(
8845        r#"<div class="cocomo-box own-result-box">
8846      <div class="cocomo-box-head">
8847        <span class="cocomo-box-title">Code Ownership</span>
8848        <span class="own-result-count">{count} contributor{plural}</span>
8849      </div>
8850      <p class="cocomo-box-note own-result-intro">Per-author line ownership from <strong>git blame</strong>. GitHub no-reply aliases fold into a contributor's real email automatically; use <strong>Combine contributors</strong> below to merge across different emails &mdash; applied on top of the scan with no re-scan, and reflected right here on this report.</p>
8851      <div class="own-result-scroll">
8852        <table class="own-result-table">
8853          <thead><tr><th>Author</th><th>Email</th><th class="num">Code</th><th class="num">Comment</th><th class="num">Blank</th><th class="num">Total</th><th class="num">Code %</th><th class="num">Files</th></tr></thead>
8854          <tbody>{author_table}</tbody>
8855        </table>
8856      </div>
8857      {merge_panel}
8858    </div>"#,
8859        count = rows.len(),
8860        plural = plural,
8861        author_table = author_table,
8862        merge_panel = merge_panel,
8863    )
8864}
8865
8866fn render_result_page(
8867    run: &AnalysisRun,
8868    artifacts: &RunArtifacts,
8869    run_id: &str,
8870    csp_nonce: &str,
8871    confluence_configured: bool,
8872    server_mode: bool,
8873    merge_groups: &[AuthorMergeGroup],
8874) -> Response {
8875    let ctx = &artifacts.result_context;
8876    let prev_entry = &ctx.prev_entry;
8877    let prev_scan_count = ctx.prev_scan_count;
8878    // `result_context` is empty when the run is recovered from the scan registry (e.g. reopening a
8879    // past report). Fall back to the scanned roots recorded in the run JSON so the "Project path"
8880    // field is never blank.
8881    let project_path_owned = if ctx.project_path.is_empty() {
8882        run.input_roots.join(", ")
8883    } else {
8884        ctx.project_path.clone()
8885    };
8886    let project_path = &project_path_owned;
8887
8888    let scan_delta = prev_entry.as_ref().and_then(|prev| {
8889        prev.json_path
8890            .as_ref()
8891            .and_then(|p| read_json(p).ok())
8892            .map(|prev_run| compute_delta(&prev_run, run))
8893    });
8894
8895    let files_analyzed = run.per_file_records.len() as u64;
8896    let files_skipped = run.skipped_file_records.len() as u64;
8897    let totals = sum_lang_totals(run);
8898
8899    let DeltaFields {
8900        prev_fa_str,
8901        prev_fs_str,
8902        prev_pl_str,
8903        prev_cl_str,
8904        prev_cml_str,
8905        prev_bl_str,
8906        delta_fa_str,
8907        delta_fa_class,
8908        delta_fs_str,
8909        delta_fs_class,
8910        delta_pl_str,
8911        delta_pl_class,
8912        delta_cl_str,
8913        delta_cl_class,
8914        delta_cml_str,
8915        delta_cml_class,
8916        delta_bl_str,
8917        delta_bl_class,
8918        delta_lines_added,
8919        delta_lines_removed,
8920        delta_lines_net_str,
8921        delta_lines_net_class,
8922    } = compute_delta_fields(
8923        prev_entry.as_ref(),
8924        &totals,
8925        files_analyzed,
8926        files_skipped,
8927        scan_delta.as_ref(),
8928    );
8929
8930    let run_dir = artifacts.output_dir.clone();
8931    let git_branch = run.git_branch.clone();
8932    let git_commit = run.git_commit_short.clone();
8933    let git_commit_long = run.git_commit_long.clone();
8934    let git_author = run.git_commit_author.clone();
8935    let git_commit_url = git_commit_url_for(run);
8936    let git_branch_url = git_branch_url_for(run);
8937    let scan_performed_by = scan_performed_by(run);
8938    let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
8939    let os_display = format!(
8940        "{} / {}",
8941        run.environment.operating_system, run.environment.architecture
8942    );
8943    let test_count = run.summary_totals.test_count;
8944
8945    // ── New metrics ──────────────────────────────────────────────────────────
8946    let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
8947    let lsloc = run.summary_totals.lsloc;
8948    let uloc = run.uloc;
8949    let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
8950    let duplicate_group_count = run.duplicate_groups.len();
8951
8952    // Re-compute COCOMO with the mode selected in the scan wizard.
8953    let ctx = &artifacts.result_context;
8954    let CocomoFields {
8955        has_cocomo,
8956        effort_str: cocomo_effort_str,
8957        duration_str: cocomo_duration_str,
8958        staff_str: cocomo_staff_str,
8959        ksloc_str: cocomo_ksloc_str,
8960        mode_label: cocomo_mode_label,
8961        mode_tooltip: cocomo_mode_tooltip,
8962    } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
8963    let complexity_alert = ctx.complexity_alert;
8964
8965    let ownership_html =
8966        render_result_ownership_section(run, merge_groups, &format!("/runs/result/{run_id}"));
8967
8968    let template = ResultTemplate {
8969        version: env!("CARGO_PKG_VERSION"),
8970        report_title: run.effective_configuration.reporting.report_title.clone(),
8971        project_path: project_path.clone(),
8972        output_dir: display_path(&artifacts.output_dir),
8973        run_id: run_id.to_owned(),
8974        run_id_short: run_id
8975            .split('-')
8976            .next_back()
8977            .unwrap_or(run_id)
8978            .chars()
8979            .take(7)
8980            .collect(),
8981        files_analyzed,
8982        files_skipped,
8983        physical_lines: totals.physical_lines,
8984        code_lines: totals.code_lines,
8985        comment_lines: totals.comment_lines,
8986        blank_lines: totals.blank_lines,
8987        mixed_lines: totals.mixed_lines,
8988        functions: totals.functions,
8989        classes: totals.classes,
8990        variables: totals.variables,
8991        imports: totals.imports,
8992        html_url: artifacts
8993            .html_path
8994            .as_ref()
8995            .map(|_| format!("/runs/html/{run_id}")),
8996        pdf_url: artifacts
8997            .pdf_path
8998            .as_ref()
8999            .map(|_| format!("/runs/pdf/{run_id}")),
9000        json_url: artifacts
9001            .json_path
9002            .as_ref()
9003            .map(|_| format!("/runs/json/{run_id}")),
9004        html_download_url: artifacts
9005            .html_path
9006            .as_ref()
9007            .map(|_| format!("/runs/html/{run_id}?download=1")),
9008        pdf_download_url: artifacts
9009            .pdf_path
9010            .as_ref()
9011            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
9012        json_download_url: artifacts
9013            .json_path
9014            .as_ref()
9015            .map(|_| format!("/runs/json/{run_id}?download=1")),
9016        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
9017        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
9018        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
9019        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
9020        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
9021        prev_fa_str,
9022        prev_fs_str,
9023        prev_pl_str,
9024        prev_cl_str,
9025        prev_cml_str,
9026        prev_bl_str,
9027        delta_fa_str,
9028        delta_fa_class,
9029        delta_fs_str,
9030        delta_fs_class,
9031        delta_pl_str,
9032        delta_pl_class,
9033        delta_cl_str,
9034        delta_cl_class,
9035        delta_cml_str,
9036        delta_cml_class,
9037        delta_bl_str,
9038        delta_bl_class,
9039        delta_lines_added,
9040        delta_lines_removed,
9041        delta_lines_net_str,
9042        delta_lines_net_class,
9043        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
9044        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
9045        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
9046        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
9047        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
9048        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
9049        git_branch,
9050        git_branch_url,
9051        git_commit,
9052        git_commit_long,
9053        git_author,
9054        git_commit_url,
9055        scan_performed_by,
9056        scan_time_display,
9057        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
9058        os_display,
9059        test_count,
9060        test_assertion_count: run.summary_totals.test_assertion_count,
9061        current_scan_number: prev_scan_count + 1,
9062        prev_scan_count,
9063        submodule_rows: run
9064            .submodule_summaries
9065            .iter()
9066            .map(|s| build_submodule_row(s, run, run_id, &run_dir))
9067            .collect(),
9068        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
9069        scan_config_url: format!("/runs/scan-config/{run_id}"),
9070        lang_chart_json: build_lang_chart_json(run),
9071        scatter_chart_json: build_scatter_chart_json(run),
9072        semantic_chart_json: build_semantic_chart_json(run),
9073        submodule_chart_json: build_submodule_chart_json(run),
9074        has_submodule_data: !run.submodule_summaries.is_empty(),
9075        has_semantic_data: run
9076            .totals_by_language
9077            .iter()
9078            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
9079        csp_nonce: csp_nonce.to_owned(),
9080        confluence_configured,
9081        server_mode,
9082        report_header_footer: run
9083            .effective_configuration
9084            .reporting
9085            .report_header_footer
9086            .clone(),
9087        is_offline: false,
9088        cyclomatic_complexity,
9089        lsloc,
9090        uloc,
9091        dryness_pct_str,
9092        duplicate_group_count,
9093        has_cocomo,
9094        cocomo_effort_str,
9095        cocomo_duration_str,
9096        cocomo_staff_str,
9097        cocomo_ksloc_str,
9098        cocomo_mode_label,
9099        cocomo_mode_tooltip,
9100        complexity_alert,
9101        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
9102        cov_line_pct: cov_pct_str(
9103            run.summary_totals.coverage_lines_hit,
9104            run.summary_totals.coverage_lines_found,
9105        ),
9106        cov_fn_pct: cov_pct_str(
9107            run.summary_totals.coverage_functions_hit,
9108            run.summary_totals.coverage_functions_found,
9109        ),
9110        cov_branch_pct: cov_pct_str(
9111            run.summary_totals.coverage_branches_hit,
9112            run.summary_totals.coverage_branches_found,
9113        ),
9114        cov_lines_summary: cov_lines_summary_str(
9115            run.summary_totals.coverage_lines_hit,
9116            run.summary_totals.coverage_lines_found,
9117        ),
9118        ownership_html,
9119    };
9120
9121    Html(
9122        template
9123            .render()
9124            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
9125    )
9126    .into_response()
9127}
9128
9129fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
9130    let slug: String = report_title
9131        .chars()
9132        .map(|c| {
9133            if c.is_alphanumeric() || c == '-' {
9134                c.to_ascii_lowercase()
9135            } else {
9136                '_'
9137            }
9138        })
9139        .collect::<String>()
9140        .split('_')
9141        .filter(|s| !s.is_empty())
9142        .collect::<Vec<_>>()
9143        .join("_");
9144
9145    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
9146
9147    if slug.is_empty() {
9148        format!("report_{short_id}.pdf")
9149    } else {
9150        format!("{slug}_{short_id}.pdf")
9151    }
9152}
9153
9154#[derive(Serialize)]
9155struct PdfStatusResponse {
9156    ready: bool,
9157}
9158
9159/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
9160/// Clients poll this to update the button state without page reloads.
9161async fn pdf_status_handler(
9162    State(state): State<AppState>,
9163    AxumPath(run_id): AxumPath<String>,
9164) -> Response {
9165    let pdf_path = {
9166        let registry = state.artifacts.lock().await;
9167        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
9168    };
9169    let pdf_path = if pdf_path.is_some() {
9170        pdf_path
9171    } else {
9172        let reg = state.registry.lock().await;
9173        reg.find_by_run_id(&run_id)
9174            .map(recover_artifacts_from_registry)
9175            .and_then(|a| a.pdf_path)
9176    };
9177    let ready = pdf_path.is_some_and(|p| p.exists());
9178    Json(PdfStatusResponse { ready }).into_response()
9179}
9180
9181/// GET /`api/runs/:run_id/bundle`
9182///
9183/// Streams a gzip-compressed tar archive containing every artifact in the run's
9184/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
9185/// is built in memory so it never touches a temp file.
9186async fn download_bundle_handler(
9187    State(state): State<AppState>,
9188    AxumPath(run_id): AxumPath<String>,
9189) -> Response {
9190    // Resolve output directory from in-memory cache or persisted registry.
9191    let output_dir = {
9192        let cache = state.artifacts.lock().await;
9193        cache.get(&run_id).map(|a| a.output_dir.clone())
9194    };
9195    let output_dir = if let Some(d) = output_dir {
9196        d
9197    } else {
9198        let reg = state.registry.lock().await;
9199        match reg.find_by_run_id(&run_id) {
9200            Some(entry) => recover_artifacts_from_registry(entry).output_dir,
9201            None => {
9202                return (
9203                    StatusCode::NOT_FOUND,
9204                    Json(serde_json::json!({"error": "Run not found"})),
9205                )
9206                    .into_response();
9207            }
9208        }
9209    };
9210
9211    if !output_dir.exists() {
9212        return (
9213            StatusCode::NOT_FOUND,
9214            Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
9215        )
9216            .into_response();
9217    }
9218
9219    // Build tar.gz in a blocking thread to avoid blocking the async runtime.
9220    let run_id_clone = run_id.clone();
9221    let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
9222        use flate2::{Compression, write::GzEncoder};
9223        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
9224        {
9225            let mut tar = tar::Builder::new(&mut enc);
9226            tar.follow_symlinks(false);
9227            // Append every regular file in the output directory, skipping
9228            // sub-directories (the output dir is always flat).
9229            if let Ok(entries) = std::fs::read_dir(&output_dir) {
9230                for entry in entries.filter_map(Result::ok) {
9231                    let p = entry.path();
9232                    if p.is_file() {
9233                        let name = p.file_name().unwrap_or_default().to_string_lossy();
9234                        let archive_path = format!("{run_id_clone}/{name}");
9235                        tar.append_path_with_name(&p, &archive_path)?;
9236                    }
9237                }
9238            }
9239            tar.finish()?;
9240        }
9241        Ok(enc.finish()?)
9242    })
9243    .await;
9244
9245    match archive_result {
9246        Ok(Ok(bytes)) => {
9247            let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
9248            axum::response::Response::builder()
9249                .status(StatusCode::OK)
9250                .header("Content-Type", "application/gzip")
9251                .header(
9252                    "Content-Disposition",
9253                    format!("attachment; filename=\"{filename}\""),
9254                )
9255                .header("Content-Length", bytes.len().to_string())
9256                .body(axum::body::Body::from(bytes))
9257                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
9258        }
9259        Ok(Err(e)) => (
9260            StatusCode::INTERNAL_SERVER_ERROR,
9261            Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
9262        )
9263            .into_response(),
9264        Err(e) => (
9265            StatusCode::INTERNAL_SERVER_ERROR,
9266            Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
9267        )
9268            .into_response(),
9269    }
9270}
9271
9272/// DELETE /`api/runs/:run_id`
9273///
9274/// Removes all on-disk artifacts for the run and purges the run from the
9275/// in-memory cache and the persisted registry. Returns 204 on success.
9276async fn delete_run_handler(
9277    State(state): State<AppState>,
9278    AxumPath(run_id): AxumPath<String>,
9279) -> Response {
9280    // Resolve output directory.
9281    let output_dir = {
9282        let mut cache = state.artifacts.lock().await;
9283        let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
9284        cache.remove(&run_id);
9285        dir
9286    };
9287    let output_dir = if let Some(d) = output_dir {
9288        d
9289    } else {
9290        let reg = state.registry.lock().await;
9291        reg.find_by_run_id(&run_id)
9292            .map(|e| recover_artifacts_from_registry(e).output_dir)
9293            .unwrap_or_default()
9294    };
9295
9296    // Remove from persisted registry.
9297    {
9298        let mut reg = state.registry.lock().await;
9299        reg.entries.retain(|e| e.run_id != run_id);
9300        let _ = reg.save(&state.registry_path);
9301    }
9302
9303    // Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
9304    // a prior delete may have already removed the directory.
9305    if output_dir.exists() {
9306        match tokio::fs::remove_dir_all(&output_dir).await {
9307            Ok(()) => {}
9308            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
9309            Err(e) => {
9310                return (
9311                    StatusCode::INTERNAL_SERVER_ERROR,
9312                    Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
9313                )
9314                    .into_response();
9315            }
9316        }
9317    }
9318
9319    StatusCode::NO_CONTENT.into_response()
9320}
9321
9322// ── Export / publish ───────────────────────────────────────────────────────────
9323
9324/// Operator-configured export destination directory (a mounted network/share drive or
9325/// any local path). From `SLOC_EXPORT_DIR`. `None` when unset/empty. This is set by the
9326/// host operator via environment/systemd — never by a web client — so the destination is
9327/// trusted and not subject to the scan-path allowlist.
9328fn export_share_dir() -> Option<PathBuf> {
9329    std::env::var("SLOC_EXPORT_DIR")
9330        .ok()
9331        .map(|s| s.trim().to_owned())
9332        .filter(|s| !s.is_empty())
9333        .map(PathBuf::from)
9334}
9335
9336/// Whether a finished scan should be auto-published to the configured export target.
9337/// From `SLOC_EXPORT_AUTO`. Off by default — export is a manual, admin-triggered action
9338/// unless the operator opts in.
9339fn export_auto_enabled() -> bool {
9340    matches!(
9341        std::env::var("SLOC_EXPORT_AUTO").as_deref(),
9342        Ok("1" | "true" | "TRUE")
9343    )
9344}
9345
9346#[derive(Debug, Deserialize, Default)]
9347struct ExportQuery {
9348    /// Export target: `share` (default) copies artifacts to `SLOC_EXPORT_DIR`.
9349    target: Option<String>,
9350}
9351
9352/// Resolve a run's on-disk artifact directory from the in-memory cache or the registry.
9353/// Returns an empty path when the run is unknown (callers treat that as 404).
9354async fn resolve_run_output_dir(state: &AppState, run_id: &str) -> PathBuf {
9355    if let Some(d) = state
9356        .artifacts
9357        .lock()
9358        .await
9359        .get(run_id)
9360        .map(|a| a.output_dir.clone())
9361    {
9362        return d;
9363    }
9364    let reg = state.registry.lock().await;
9365    reg.find_by_run_id(run_id)
9366        .map(|e| recover_artifacts_from_registry(e).output_dir)
9367        .unwrap_or_default()
9368}
9369
9370/// Copy a run's artifact directory into the configured share drive, under a subdirectory
9371/// named after the run's output folder. Returns the JSON response describing the result.
9372async fn export_run_to_share(output_dir: PathBuf) -> Response {
9373    let Some(dest_root) = export_share_dir() else {
9374        return (
9375            StatusCode::CONFLICT,
9376            Json(serde_json::json!({
9377                "error": "Share export is not configured. Set SLOC_EXPORT_DIR to a destination \
9378                          directory (e.g. a mounted network share) and restart."
9379            })),
9380        )
9381            .into_response();
9382    };
9383    let sub = output_dir
9384        .file_name()
9385        .map_or_else(|| "export".into(), std::ffi::OsStr::to_os_string);
9386    let dest = dest_root.join(&sub);
9387    let src = output_dir.clone();
9388    let dest_for_task = dest.clone();
9389    match tokio::task::spawn_blocking(move || sloc_core::copy_tree(&src, &dest_for_task)).await {
9390        Ok(Ok((files, bytes))) => {
9391            audit::record(
9392                "run_exported",
9393                "success",
9394                &[
9395                    ("target", "share"),
9396                    ("dest", &dest.display().to_string()),
9397                    ("files", &files.to_string()),
9398                ],
9399            );
9400            Json(serde_json::json!({
9401                "target": "share",
9402                "dest": dest.display().to_string(),
9403                "files": files,
9404                "bytes": bytes,
9405            }))
9406            .into_response()
9407        }
9408        Ok(Err(e)) => {
9409            tracing::warn!(event = "export_error", "share export failed: {e}");
9410            (
9411                StatusCode::INTERNAL_SERVER_ERROR,
9412                Json(serde_json::json!({"error": format!("Export failed: {e}")})),
9413            )
9414                .into_response()
9415        }
9416        Err(e) => {
9417            tracing::error!(event = "export_task_panic", "export task panicked: {e}");
9418            (
9419                StatusCode::INTERNAL_SERVER_ERROR,
9420                Json(serde_json::json!({"error": "Internal server error"})),
9421            )
9422                .into_response()
9423        }
9424    }
9425}
9426
9427/// Operator-configured git export target: `SLOC_EXPORT_GIT_REPO` (repo URL, required),
9428/// `SLOC_EXPORT_GIT_BRANCH` (default `oxide-sloc-reports`). `None` repo ⇒ not configured.
9429fn export_git_target() -> Option<(String, String)> {
9430    let repo = std::env::var("SLOC_EXPORT_GIT_REPO")
9431        .ok()
9432        .map(|s| s.trim().to_owned())
9433        .filter(|s| !s.is_empty())?;
9434    let branch = std::env::var("SLOC_EXPORT_GIT_BRANCH")
9435        .ok()
9436        .map(|s| s.trim().to_owned())
9437        .filter(|s| !s.is_empty())
9438        .unwrap_or_else(|| "oxide-sloc-reports".to_string());
9439    Some((repo, branch))
9440}
9441
9442/// Publish a run's artifacts to the operator-configured git repository, under a per-run
9443/// subdirectory on the configured branch. Reuses `sloc-git`'s clone/credential/SSRF gates.
9444async fn export_run_to_git(output_dir: PathBuf) -> Response {
9445    let Some((repo, branch)) = export_git_target() else {
9446        return (
9447            StatusCode::CONFLICT,
9448            Json(serde_json::json!({
9449                "error": "Git export is not configured. Set SLOC_EXPORT_GIT_REPO (and \
9450                          optionally SLOC_EXPORT_GIT_BRANCH) and restart."
9451            })),
9452        )
9453            .into_response();
9454    };
9455    let subdir = output_dir.file_name().map_or_else(
9456        || "export".to_string(),
9457        |n| n.to_string_lossy().into_owned(),
9458    );
9459    let work_dir = std::env::temp_dir()
9460        .join("oxide-sloc-export")
9461        .join(uuid::Uuid::new_v4().to_string());
9462    let message = format!("oxide-sloc: publish {subdir}");
9463    let (repo_c, branch_c, subdir_c) = (repo.clone(), branch.clone(), subdir.clone());
9464    let src = output_dir.clone();
9465    let work_for_task = work_dir.clone();
9466    let result = tokio::task::spawn_blocking(move || {
9467        sloc_git::publish_dir(
9468            &repo_c,
9469            &branch_c,
9470            &subdir_c,
9471            &src,
9472            &message,
9473            &work_for_task,
9474        )
9475    })
9476    .await;
9477    let _ = tokio::fs::remove_dir_all(&work_dir).await; // best-effort scratch cleanup
9478    match result {
9479        Ok(Ok(())) => {
9480            audit::record(
9481                "run_exported",
9482                "success",
9483                &[("target", "git"), ("repo", &repo), ("branch", &branch)],
9484            );
9485            Json(serde_json::json!({
9486                "target": "git", "repo": repo, "branch": branch, "subdir": subdir,
9487            }))
9488            .into_response()
9489        }
9490        Ok(Err(e)) => {
9491            tracing::warn!(event = "export_error", "git export failed: {e:#}");
9492            (
9493                StatusCode::BAD_GATEWAY,
9494                Json(serde_json::json!({"error": format!("Git export failed: {e}")})),
9495            )
9496                .into_response()
9497        }
9498        Err(e) => {
9499            tracing::error!(event = "export_task_panic", "git export task panicked: {e}");
9500            (
9501                StatusCode::INTERNAL_SERVER_ERROR,
9502                Json(serde_json::json!({"error": "Internal server error"})),
9503            )
9504                .into_response()
9505        }
9506    }
9507}
9508
9509/// POST /api/runs/{run_id}/export
9510///
9511/// Publish a completed run's artifacts to an operator-configured export target: `share`
9512/// (copy to `SLOC_EXPORT_DIR`), `git` (push to `SLOC_EXPORT_GIT_REPO`), or `confluence`
9513/// (the configured Confluence space). Auth-gated like the other `/api/runs/*` mutating
9514/// routes; a read-only key cannot invoke it (it is a POST).
9515async fn export_run_handler(
9516    State(state): State<AppState>,
9517    AxumPath(run_id): AxumPath<String>,
9518    Query(query): Query<ExportQuery>,
9519) -> Response {
9520    let output_dir = resolve_run_output_dir(&state, &run_id).await;
9521    if output_dir.as_os_str().is_empty() || !output_dir.exists() {
9522        return (
9523            StatusCode::NOT_FOUND,
9524            Json(serde_json::json!({"error": "Unknown run or its artifacts are gone"})),
9525        )
9526            .into_response();
9527    }
9528    match query.target.as_deref().unwrap_or("share") {
9529        "share" => export_run_to_share(output_dir).await,
9530        "git" => export_run_to_git(output_dir).await,
9531        "confluence" => {
9532            // Reuse the shared Confluence publish flow; derive a stable page title from
9533            // the run's project label so re-exports update the same page.
9534            let title = {
9535                let reg = state.registry.lock().await;
9536                reg.find_by_run_id(&run_id)
9537                    .map(|e| format!("oxide-sloc — {}", e.project_label))
9538            }
9539            .unwrap_or_else(|| "oxide-sloc SLOC report".to_string());
9540            confluence::publish_run(&state, &run_id, &title, None).await
9541        }
9542        other => (
9543            StatusCode::BAD_REQUEST,
9544            Json(serde_json::json!({
9545                "error": format!(
9546                    "Unsupported export target '{other}'. Supported: share, git, confluence"
9547                )
9548            })),
9549        )
9550            .into_response(),
9551    }
9552}
9553
9554/// GET /api/admin/config
9555///
9556/// Operator-only, read-only view of the effective server configuration: which security
9557/// controls are active, the disk ceiling, the export target, and the hostname settings.
9558/// When `SLOC_ADMIN_KEY` is configured this endpoint requires it specifically (a plain
9559/// user key is not enough); when it is unset, the surrounding API-key gate applies.
9560/// Never returns secret values — only whether each is set.
9561async fn api_admin_config(
9562    State(state): State<AppState>,
9563    headers: axum::http::HeaderMap,
9564) -> Response {
9565    if auth::admin_key_configured() && !auth::is_admin_request(&headers) {
9566        return (
9567            StatusCode::FORBIDDEN,
9568            Json(serde_json::json!({
9569                "error": "This endpoint requires the operator admin key (SLOC_ADMIN_KEY)."
9570            })),
9571        )
9572            .into_response();
9573    }
9574    let policy_mb = {
9575        let store = state.cleanup_policy.lock().await;
9576        store.policy.as_ref().and_then(|p| p.max_total_mb)
9577    };
9578    let env_disk_mb = std::env::var("SLOC_MAX_DISK_MB")
9579        .ok()
9580        .and_then(|v| v.trim().parse::<u64>().ok());
9581    let scan_roots: Vec<String> = state
9582        .base_config
9583        .discovery
9584        .allowed_scan_roots
9585        .iter()
9586        .map(|p| p.display().to_string())
9587        .collect();
9588    Json(serde_json::json!({
9589        "server_mode": state.server_mode,
9590        "auth": {
9591            "api_keys_configured": !state.api_keys.is_empty(),
9592            "readonly_keys_configured": !state.readonly_api_keys.is_empty(),
9593            "admin_key_configured": auth::admin_key_configured(),
9594            "allow_unauthenticated": state.allow_unauthenticated,
9595        },
9596        "tls_enabled": state.tls_enabled,
9597        "disk_cap": {
9598            "env_max_disk_mb": env_disk_mb,
9599            "policy_max_total_mb": policy_mb,
9600            "effective_bytes": disk_cap_bytes(policy_mb),
9601        },
9602        "export": {
9603            "share_dir": export_share_dir().map(|p| p.display().to_string()),
9604            "auto": export_auto_enabled(),
9605        },
9606        "hostname": {
9607            "public_url": std::env::var("SLOC_PUBLIC_URL").ok().filter(|s| !s.trim().is_empty()),
9608            "allowed_hosts": effective_allowed_hosts(),
9609        },
9610        "allowed_scan_roots": scan_roots,
9611    }))
9612    .into_response()
9613}
9614
9615/// POST /api/runs/cleanup
9616///
9617/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
9618/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
9619async fn cleanup_runs_handler(
9620    State(state): State<AppState>,
9621    Json(body): Json<serde_json::Value>,
9622) -> Response {
9623    let days = body
9624        .get("older_than_days")
9625        .and_then(serde_json::Value::as_u64)
9626        .unwrap_or(30)
9627        .max(1);
9628
9629    let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
9630
9631    // Collect expired entries from the registry.
9632    let expired: Vec<(String, PathBuf)> = {
9633        let reg = state.registry.lock().await;
9634        reg.entries
9635            .iter()
9636            .filter(|e| e.timestamp_utc < cutoff)
9637            .map(|e| {
9638                let arts = recover_artifacts_from_registry(e);
9639                (e.run_id.clone(), arts.output_dir)
9640            })
9641            .collect()
9642    };
9643
9644    let mut deleted = 0usize;
9645    for (run_id, output_dir) in &expired {
9646        // Remove from in-memory cache.
9647        state.artifacts.lock().await.remove(run_id);
9648        // Delete on-disk artifacts (non-fatal if already gone).
9649        if output_dir.exists()
9650            && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
9651        {
9652            eprintln!(
9653                "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
9654                output_dir.display()
9655            );
9656            continue;
9657        }
9658        deleted += 1;
9659    }
9660
9661    // Purge expired run IDs from the registry in one pass.
9662    let expired_ids: std::collections::HashSet<&str> =
9663        expired.iter().map(|(id, _)| id.as_str()).collect();
9664    {
9665        let mut reg = state.registry.lock().await;
9666        reg.entries
9667            .retain(|e| !expired_ids.contains(e.run_id.as_str()));
9668        let _ = reg.save(&state.registry_path);
9669    }
9670
9671    Json(serde_json::json!({ "deleted": deleted })).into_response()
9672}
9673
9674/// Spawns the background auto-cleanup task. Returns a handle so the caller can
9675/// abort it when the policy is updated or disabled.
9676fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
9677    tokio::spawn(async move {
9678        loop {
9679            let interval_secs = {
9680                let store = state.cleanup_policy.lock().await;
9681                match &store.policy {
9682                    Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
9683                    _ => break,
9684                }
9685            };
9686            tokio::time::sleep(Duration::from_secs(interval_secs)).await;
9687            let n = run_auto_cleanup(&state).await;
9688            tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
9689        }
9690    })
9691}
9692
9693fn collect_runs_to_delete(
9694    reg: &ScanRegistry,
9695    max_age_days: Option<u32>,
9696    max_run_count: Option<u32>,
9697) -> std::collections::HashSet<String> {
9698    let mut to_delete = std::collections::HashSet::new();
9699    if let Some(days) = max_age_days {
9700        let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
9701        for e in &reg.entries {
9702            if e.timestamp_utc < cutoff {
9703                to_delete.insert(e.run_id.clone());
9704            }
9705        }
9706    }
9707    if let Some(max_count) = max_run_count {
9708        // entries are sorted newest-first; skip the ones we keep
9709        for e in reg.entries.iter().skip(max_count as usize) {
9710            to_delete.insert(e.run_id.clone());
9711        }
9712    }
9713    to_delete
9714}
9715
9716/// Given `(run_id, size_bytes)` pairs ordered newest-first, return the set of run IDs
9717/// that must be deleted to bring the retained total at or under `cap_bytes`. The newest
9718/// runs that collectively fit are kept; everything past the cap (the oldest) is dropped.
9719/// A `cap_bytes` of 0 selects every run.
9720fn select_runs_over_size_cap(
9721    sized_newest_first: &[(String, u64)],
9722    cap_bytes: u64,
9723) -> std::collections::HashSet<String> {
9724    let mut to_delete = std::collections::HashSet::new();
9725    let mut running: u64 = 0;
9726    for (run_id, size) in sized_newest_first {
9727        running = running.saturating_add(*size);
9728        if running > cap_bytes {
9729            to_delete.insert(run_id.clone());
9730        }
9731    }
9732    to_delete
9733}
9734
9735/// Resolve the effective total-disk cap in bytes from the operator env ceiling
9736/// (`SLOC_MAX_DISK_MB`) and the UI cleanup policy (`max_total_mb`). When both are set
9737/// the smaller wins (the operator ceiling can only tighten, never loosen, the policy).
9738/// Returns `None` when neither is configured.
9739fn disk_cap_bytes(policy_max_total_mb: Option<u64>) -> Option<u64> {
9740    let env_mb = std::env::var("SLOC_MAX_DISK_MB")
9741        .ok()
9742        .and_then(|v| v.trim().parse::<u64>().ok());
9743    combine_disk_caps_mb(env_mb, policy_max_total_mb).map(|mb| mb.saturating_mul(1024 * 1024))
9744}
9745
9746/// Pure combining rule for the two disk-cap sources: the smaller of the operator env
9747/// ceiling and the UI policy value, treating `0`/absent as "unset". Split out so the
9748/// selection logic is unit-testable without mutating process environment.
9749fn combine_disk_caps_mb(env_mb: Option<u64>, policy_mb: Option<u64>) -> Option<u64> {
9750    match (env_mb.filter(|&mb| mb > 0), policy_mb.filter(|&mb| mb > 0)) {
9751        (Some(a), Some(b)) => Some(a.min(b)),
9752        (Some(a), None) => Some(a),
9753        (None, Some(b)) => Some(b),
9754        (None, None) => None,
9755    }
9756}
9757
9758/// Delete the oldest runs until the retained artifact tree fits under the effective
9759/// disk cap. Runs regardless of whether the age/count policy is enabled, so the
9760/// operator `SLOC_MAX_DISK_MB` ceiling is honoured even with no UI policy configured.
9761/// Returns the number of runs deleted.
9762async fn enforce_disk_cap(state: &AppState) -> u32 {
9763    let policy_mb = {
9764        let store = state.cleanup_policy.lock().await;
9765        store.policy.as_ref().and_then(|p| p.max_total_mb)
9766    };
9767    let Some(cap_bytes) = disk_cap_bytes(policy_mb) else {
9768        return 0;
9769    };
9770
9771    // Snapshot (run_id, output_dir) newest-first, then size each dir off the async
9772    // executor so the recursive walk never blocks the runtime.
9773    let dirs: Vec<(String, PathBuf)> = {
9774        let reg = state.registry.lock().await;
9775        reg.entries
9776            .iter()
9777            .map(|e| {
9778                (
9779                    e.run_id.clone(),
9780                    recover_artifacts_from_registry(e).output_dir,
9781                )
9782            })
9783            .collect()
9784    };
9785    if dirs.is_empty() {
9786        return 0;
9787    }
9788    let sized = tokio::task::spawn_blocking(move || {
9789        dirs.into_iter()
9790            .map(|(id, dir)| (id, dir_size_bytes(&dir)))
9791            .collect::<Vec<_>>()
9792    })
9793    .await
9794    .unwrap_or_default();
9795
9796    let to_delete = select_runs_over_size_cap(&sized, cap_bytes);
9797    if to_delete.is_empty() {
9798        return 0;
9799    }
9800    for run_id in &to_delete {
9801        delete_run_artifacts(state, run_id).await;
9802    }
9803    {
9804        let mut reg = state.registry.lock().await;
9805        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
9806        let _ = reg.save(&state.registry_path);
9807    }
9808    tracing::warn!(
9809        event = "disk_cap_enforced",
9810        deleted = to_delete.len(),
9811        cap_bytes,
9812        "artifact tree exceeded disk cap; deleted oldest runs"
9813    );
9814    u32::try_from(to_delete.len()).unwrap_or(u32::MAX)
9815}
9816
9817/// Background watchdog that enforces the `SLOC_MAX_DISK_MB` operator ceiling (and any
9818/// UI `max_total_mb`) on a fixed short interval, independent of the age/count policy
9819/// task. Spawned in server mode so a flood of uploads/scans can never fill the host
9820/// disk between the (possibly daily) policy passes. A no-op when no cap is configured.
9821fn spawn_disk_guard(state: AppState) {
9822    tokio::spawn(async move {
9823        let mut interval = tokio::time::interval(Duration::from_mins(10));
9824        interval.tick().await; // consume the immediate first tick
9825        loop {
9826            interval.tick().await;
9827            let n = enforce_disk_cap(&state).await;
9828            if n > 0 {
9829                tracing::info!("[disk-guard] enforced disk cap: deleted {n} runs");
9830            }
9831        }
9832    });
9833}
9834
9835async fn delete_run_artifacts(state: &AppState, run_id: &str) {
9836    let output_dir = {
9837        let mut cache = state.artifacts.lock().await;
9838        let d = cache.get(run_id).map(|a| a.output_dir.clone());
9839        cache.remove(run_id);
9840        d
9841    };
9842    let output_dir = if let Some(d) = output_dir {
9843        d
9844    } else {
9845        let reg = state.registry.lock().await;
9846        reg.find_by_run_id(run_id)
9847            .map(|e| recover_artifacts_from_registry(e).output_dir)
9848            .unwrap_or_default()
9849    };
9850    if output_dir.exists() {
9851        let _ = tokio::fs::remove_dir_all(&output_dir).await;
9852    }
9853}
9854
9855/// Core cleanup logic shared by the background task and the "Run Now" handler.
9856/// Applies both the age limit and the count limit, then updates `last_run_at`.
9857/// Returns the number of runs deleted.
9858async fn run_auto_cleanup(state: &AppState) -> u32 {
9859    let (max_age_days, max_run_count) = {
9860        let store = state.cleanup_policy.lock().await;
9861        match &store.policy {
9862            Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
9863            // Age/count cleanup is disabled, but a disk-size ceiling may still apply
9864            // (operator SLOC_MAX_DISK_MB or a size-only policy), so fall through to it.
9865            _ => (None, None),
9866        }
9867    };
9868
9869    let to_delete = {
9870        let reg = state.registry.lock().await;
9871        collect_runs_to_delete(&reg, max_age_days, max_run_count)
9872    };
9873
9874    for run_id in &to_delete {
9875        delete_run_artifacts(state, run_id).await;
9876    }
9877
9878    // Purge from registry.
9879    if !to_delete.is_empty() {
9880        let mut reg = state.registry.lock().await;
9881        reg.entries.retain(|e| !to_delete.contains(&e.run_id));
9882        let _ = reg.save(&state.registry_path);
9883    }
9884
9885    // Enforce the total-disk-size ceiling last, after the age/count deletions have
9886    // already freed what they can.
9887    let size_deleted = enforce_disk_cap(state).await;
9888
9889    let deleted = u32::try_from(to_delete.len())
9890        .unwrap_or(u32::MAX)
9891        .saturating_add(size_deleted);
9892    {
9893        let mut store = state.cleanup_policy.lock().await;
9894        store.last_run_at = Some(chrono::Utc::now());
9895        store.last_run_deleted = Some(deleted);
9896        let _ = store.save(&state.cleanup_policy_path);
9897    }
9898    deleted
9899}
9900
9901// ── Auto-cleanup policy API ───────────────────────────────────────────────────
9902
9903/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
9904async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
9905    let store = state.cleanup_policy.lock().await;
9906    Json(serde_json::json!({
9907        "policy": store.policy,
9908        "last_run_at": store.last_run_at,
9909        "last_run_deleted": store.last_run_deleted,
9910    }))
9911    .into_response()
9912}
9913
9914/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
9915async fn api_save_cleanup_policy(
9916    State(state): State<AppState>,
9917    Json(mut body): Json<CleanupPolicy>,
9918) -> Response {
9919    // "The UI can only tighten": clamp the requested size cap to the operator env
9920    // ceiling (`SLOC_MAX_DISK_MB`) so a web user can lower it but never raise it above
9921    // what the host operator set. A policy that omits the cap inherits the ceiling.
9922    let env_mb = std::env::var("SLOC_MAX_DISK_MB")
9923        .ok()
9924        .and_then(|v| v.trim().parse::<u64>().ok());
9925    body.max_total_mb = combine_disk_caps_mb(env_mb, body.max_total_mb);
9926    // Abort any running task so the new interval takes effect immediately.
9927    {
9928        let mut handle = state.cleanup_task_handle.lock().await;
9929        if let Some(h) = handle.take() {
9930            h.abort();
9931        }
9932    }
9933    {
9934        let mut store = state.cleanup_policy.lock().await;
9935        store.policy = Some(body.clone());
9936        if let Err(e) = store.save(&state.cleanup_policy_path) {
9937            return (
9938                StatusCode::INTERNAL_SERVER_ERROR,
9939                Json(serde_json::json!({"error": e.to_string()})),
9940            )
9941                .into_response();
9942        }
9943    }
9944    if body.enabled {
9945        let handle = spawn_cleanup_policy_task(state.clone());
9946        *state.cleanup_task_handle.lock().await = Some(handle);
9947    }
9948    StatusCode::NO_CONTENT.into_response()
9949}
9950
9951/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
9952async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
9953    let deleted = run_auto_cleanup(&state).await;
9954    Json(serde_json::json!({ "deleted": deleted })).into_response()
9955}
9956
9957/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
9958async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
9959    {
9960        let mut handle = state.cleanup_task_handle.lock().await;
9961        if let Some(h) = handle.take() {
9962            h.abort();
9963        }
9964    }
9965    {
9966        let mut store = state.cleanup_policy.lock().await;
9967        store.policy = None;
9968        let _ = store.save(&state.cleanup_policy_path);
9969    }
9970    StatusCode::NO_CONTENT.into_response()
9971}
9972
9973/// Serve the HTML artifact for a run — view or download.
9974/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
9975/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
9976/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
9977/// Only called for browser views; downloads keep the self-contained inline version.
9978fn swap_inline_chart_js_for_static(html: String) -> String {
9979    let Some(head_end) = html.find("</head>") else {
9980        return html;
9981    };
9982    let Some(script_start) = html[..head_end].rfind("<script") else {
9983        return html;
9984    };
9985    let Some(close_offset) = html[script_start..].find("</script>") else {
9986        return html;
9987    };
9988    let block_end = script_start + close_offset + "</script>".len();
9989    format!(
9990        "{}<script src=\"/static/chart-report.js\"></script>{}",
9991        &html[..script_start],
9992        &html[block_end..]
9993    )
9994}
9995
9996/// current-request Content-Security-Policy nonce check.
9997fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
9998    // Find the first nonce value that was baked in at render time.
9999    let Some(start) = html.find("nonce=\"") else {
10000        // Reports generated before nonce support was added have bare <style> and <script>
10001        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
10002        // the inline blocks — without it the browser blocks all CSS and JS.
10003        return html
10004            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
10005            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
10006    };
10007    let value_start = start + 7; // len(r#"nonce=""#) == 7
10008    let Some(end_offset) = html[value_start..].find('"') else {
10009        return html.to_owned();
10010    };
10011    let old_nonce = &html[value_start..value_start + end_offset];
10012    html.replace(
10013        &format!("nonce=\"{old_nonce}\""),
10014        &format!("nonce=\"{new_nonce}\""),
10015    )
10016}
10017
10018fn serve_html_artifact(
10019    path: &Path,
10020    wants_download: bool,
10021    csp_nonce: &str,
10022    run_id: &str,
10023    server_mode: bool,
10024) -> Response {
10025    match fs::read_to_string(path) {
10026        Ok(raw) => {
10027            // Patch the saved nonce so inline styles/scripts pass CSP.
10028            let content = patch_html_nonce(&raw, csp_nonce);
10029            if wants_download {
10030                // Keep the self-contained inline version for downloads (opened as file://).
10031                (
10032                    [
10033                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
10034                        (
10035                            header::CONTENT_DISPOSITION,
10036                            "attachment; filename=report.html",
10037                        ),
10038                    ],
10039                    content,
10040                )
10041                    .into_response()
10042            } else {
10043                // Swap the 202 KB inline Chart.js block for a cacheable static URL so the
10044                // browser caches it after the first view; the HTML response also shrinks.
10045                Html(swap_inline_chart_js_for_static(content)).into_response()
10046            }
10047        }
10048        Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
10049            let filename = path.file_name().map_or_else(
10050                || "report.html".to_string(),
10051                |n| n.to_string_lossy().into_owned(),
10052            );
10053            let html = LocateFileTemplate {
10054                run_id: run_id.to_owned(),
10055                artifact_type: "html".to_string(),
10056                expected_filename: filename,
10057                server_mode,
10058                csp_nonce: csp_nonce.to_owned(),
10059                version: env!("CARGO_PKG_VERSION"),
10060            }
10061            .render()
10062            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10063            (StatusCode::NOT_FOUND, Html(html)).into_response()
10064        }
10065        Err(err) => {
10066            let filename = path.file_name().map_or_else(
10067                || "report.html".to_string(),
10068                |n| n.to_string_lossy().into_owned(),
10069            );
10070            let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
10071            let html = ErrorTemplate {
10072                message: msg,
10073                last_report_url: Some("/view-reports".to_string()),
10074                last_report_label: Some("View Reports".to_string()),
10075                run_id: None,
10076                error_code: Some(404),
10077                csp_nonce: csp_nonce.to_owned(),
10078                version: env!("CARGO_PKG_VERSION"),
10079            }
10080            .render()
10081            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10082            (StatusCode::NOT_FOUND, Html(html)).into_response()
10083        }
10084    }
10085}
10086
10087/// Serve the PDF artifact for a run — inline or download.
10088fn serve_pdf_artifact(
10089    path: &Path,
10090    report_title: &str,
10091    run_id: &str,
10092    wants_download: bool,
10093    csp_nonce: &str,
10094) -> Response {
10095    match fs::read(path) {
10096        Ok(bytes) => {
10097            let filename = build_pdf_filename(report_title, run_id);
10098            let disposition = if wants_download {
10099                format!("attachment; filename=\"{filename}\"")
10100            } else {
10101                format!("inline; filename=\"{filename}\"")
10102            };
10103            (
10104                [
10105                    (header::CONTENT_TYPE, "application/pdf".to_string()),
10106                    (header::CONTENT_DISPOSITION, disposition),
10107                ],
10108                bytes,
10109            )
10110                .into_response()
10111        }
10112        Err(err) => {
10113            let filename = path.file_name().map_or_else(
10114                || "report.pdf".to_string(),
10115                |n| n.to_string_lossy().into_owned(),
10116            );
10117            let msg = format!(
10118                "PDF report '{filename}' could not be read.\n\n\
10119                 Error: {err}\n\n\
10120                 If you moved or renamed the output folder, the stored path is now stale. \
10121                 Use 'Open PDF folder' from the results page to browse the output directory."
10122            );
10123            let html = ErrorTemplate {
10124                message: msg,
10125                last_report_url: Some("/view-reports".to_string()),
10126                last_report_label: Some("View Reports".to_string()),
10127                run_id: Some(run_id.to_owned()),
10128                error_code: Some(404),
10129                csp_nonce: csp_nonce.to_owned(),
10130                version: env!("CARGO_PKG_VERSION"),
10131            }
10132            .render()
10133            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10134            (StatusCode::NOT_FOUND, Html(html)).into_response()
10135        }
10136    }
10137}
10138
10139/// Serve the JSON artifact for a run — view or download.
10140fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
10141    match fs::read(path) {
10142        Ok(bytes) => {
10143            if wants_download {
10144                (
10145                    [
10146                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
10147                        (
10148                            header::CONTENT_DISPOSITION,
10149                            "attachment; filename=result.json",
10150                        ),
10151                    ],
10152                    bytes,
10153                )
10154                    .into_response()
10155            } else {
10156                (
10157                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
10158                    bytes,
10159                )
10160                    .into_response()
10161            }
10162        }
10163        Err(err) => {
10164            let filename = path.file_name().map_or_else(
10165                || "result.json".to_string(),
10166                |n| n.to_string_lossy().into_owned(),
10167            );
10168            let msg = format!(
10169                "JSON result '{filename}' could not be read.\n\n\
10170                 Error: {err}\n\n\
10171                 If you moved or renamed the output folder, the stored path is now stale. \
10172                 Use 'Open JSON folder' from the results page to browse the output directory."
10173            );
10174            let html = ErrorTemplate {
10175                message: msg,
10176                last_report_url: Some("/view-reports".to_string()),
10177                last_report_label: Some("View Reports".to_string()),
10178                run_id: None,
10179                error_code: Some(404),
10180                csp_nonce: csp_nonce.to_owned(),
10181                version: env!("CARGO_PKG_VERSION"),
10182            }
10183            .render()
10184            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10185            (StatusCode::NOT_FOUND, Html(html)).into_response()
10186        }
10187    }
10188}
10189
10190/// Recover a `RunArtifacts` from the persisted registry for a run ID.
10191fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
10192    // Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
10193    // pdf/, excel/), so go up two levels. Old flat layout goes up one level.
10194    let output_dir = entry
10195        .html_path
10196        .as_ref()
10197        .or(entry.json_path.as_ref())
10198        .or(entry.pdf_path.as_ref())
10199        .or(entry.csv_path.as_ref())
10200        .or(entry.xlsx_path.as_ref())
10201        .and_then(|p| {
10202            let parent = p.parent()?;
10203            let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
10204            // New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
10205            if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
10206                parent.parent().map(PathBuf::from)
10207            } else {
10208                Some(parent.to_path_buf())
10209            }
10210        })
10211        .unwrap_or_default();
10212    // Recover pdf_path: use the persisted one, or look for report.pdf
10213    // adjacent to html/json if only the old entries lack it.
10214    let pdf_path = entry.pdf_path.clone().or_else(|| {
10215        let candidate = output_dir.join("report.pdf");
10216        candidate.exists().then_some(candidate)
10217    });
10218    // csv_path / xlsx_path: persisted paths take precedence; fall back to
10219    // scanning the run directory for files matching the expected patterns so
10220    // that runs created before this feature still surface their artifacts.
10221    let scan_dir_for = |ext: &str| -> Option<PathBuf> {
10222        // Check excel/ subfolder (new layout) then root (old layout).
10223        for dir in &[output_dir.join("excel"), output_dir.clone()] {
10224            if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
10225                entries
10226                    .filter_map(std::result::Result::ok)
10227                    .find(|e| {
10228                        let n = e.file_name();
10229                        let n = n.to_string_lossy();
10230                        n.starts_with("report_") && n.ends_with(ext)
10231                    })
10232                    .map(|e| e.path())
10233            }) {
10234                return Some(p);
10235            }
10236        }
10237        None
10238    };
10239
10240    let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
10241    let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
10242    RunArtifacts {
10243        output_dir: output_dir.clone(),
10244        html_path: entry.html_path.clone(),
10245        pdf_path,
10246        json_path: entry.json_path.clone(),
10247        csv_path,
10248        xlsx_path,
10249        scan_config_path: find_scan_config_in_dir(&output_dir),
10250        report_title: entry.project_label.clone(),
10251        result_context: RunResultContext::default(),
10252    }
10253}
10254
10255#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
10256async fn resolve_artifact_set(
10257    state: &AppState,
10258    run_id: &str,
10259    csp_nonce: &str,
10260) -> Result<RunArtifacts, Response> {
10261    let cached = state.artifacts.lock().await.get(run_id).cloned();
10262    if let Some(a) = cached {
10263        return Ok(a);
10264    }
10265    let reg = state.registry.lock().await;
10266    if let Some(entry) = reg.find_by_run_id(run_id) {
10267        return Ok(recover_artifacts_from_registry(entry));
10268    }
10269    drop(reg);
10270    let short_id = &run_id[..run_id.len().min(8)];
10271    let hint = if matches!(
10272        run_id,
10273        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
10274    ) {
10275        format!(
10276            " The URL format appears to be reversed \u{2014} \
10277             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
10278             Use the View Reports page to navigate to your scan."
10279        )
10280    } else {
10281        " The report may have been deleted or the report directory moved. \
10282         Use View Reports to browse your scan history."
10283            .to_string()
10284    };
10285    let error_html = ErrorTemplate {
10286        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
10287        last_report_url: Some("/view-reports".to_string()),
10288        last_report_label: Some("View Reports".to_string()),
10289        run_id: None,
10290        error_code: Some(404),
10291        csp_nonce: csp_nonce.to_owned(),
10292        version: env!("CARGO_PKG_VERSION"),
10293    }
10294    .render()
10295    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
10296    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
10297}
10298
10299/// Return the path to a run's PDF, queuing background generation when it is missing.
10300///
10301/// Returns `Ok(path)` when the PDF is known (it may still be generating).
10302/// Returns `Err(response)` when there is no JSON source to regenerate from.
10303// The Err type is a fully-rendered axum `Response`, the crate-wide handler error
10304// convention; boxing it here to satisfy result_large_err would break that pattern.
10305#[allow(clippy::result_large_err)]
10306async fn resolve_or_queue_pdf(
10307    state: &AppState,
10308    pdf_path: Option<PathBuf>,
10309    json_path: Option<PathBuf>,
10310    output_dir: PathBuf,
10311    run_id: &str,
10312    report_title: &str,
10313    csp_nonce: &str,
10314) -> Result<PathBuf, Response> {
10315    if let Some(p) = pdf_path {
10316        return Ok(p);
10317    }
10318    let Some(json_src) = json_path.filter(|p| p.exists()) else {
10319        let msg = "PDF report was not generated for this run. \
10320                   Re-run the analysis with PDF output enabled."
10321            .to_string();
10322        let html = ErrorTemplate {
10323            message: msg,
10324            last_report_url: Some(format!("/runs/html/{run_id}")),
10325            last_report_label: Some("View HTML Report".to_string()),
10326            run_id: Some(run_id.to_string()),
10327            error_code: Some(404),
10328            csp_nonce: csp_nonce.to_string(),
10329            version: env!("CARGO_PKG_VERSION"),
10330        }
10331        .render()
10332        .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
10333        return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
10334    };
10335    let pdf_filename = build_pdf_filename(report_title, run_id);
10336    let pdf_dest = output_dir.join(&pdf_filename);
10337    if !pdf_dest.exists() {
10338        // Record the pending path so concurrent requests show the spinner.
10339        {
10340            let mut map = state.artifacts.lock().await;
10341            if let Some(entry) = map.get_mut(run_id) {
10342                entry.pdf_path = Some(pdf_dest.clone());
10343            }
10344        }
10345        {
10346            let mut reg = state.registry.lock().await;
10347            if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
10348                e.pdf_path = Some(pdf_dest.clone());
10349            }
10350            let _ = reg.save(&state.registry_path);
10351        }
10352        spawn_native_pdf_background(
10353            json_src,
10354            pdf_dest.clone(),
10355            run_id.to_string(),
10356            state.artifacts.clone(),
10357        );
10358    }
10359    Ok(pdf_dest)
10360}
10361
10362/// Self-refreshing "please wait" page shown while the background PDF task is still running.
10363fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
10364    let html = format!(
10365        "<!doctype html><html lang=\"en\"><head>\
10366                     <meta charset=utf-8>\
10367                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
10368                     <meta http-equiv=\"refresh\" content=\"5\">\
10369                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
10370                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
10371                     <style nonce=\"{csp_nonce}\">\
10372                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
10373                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
10374                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
10375                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
10376                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
10377                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
10378                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
10379                     background:var(--bg);color:var(--text);}}\
10380                     .top-nav{{position:sticky;top:0;z-index:30;\
10381                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
10382                     border-bottom:1px solid rgba(255,255,255,0.12);\
10383                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
10384                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
10385                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
10386                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
10387                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
10388                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
10389                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
10390                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
10391                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
10392                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
10393                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
10394                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
10395                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
10396                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
10397                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
10398                     justify-content:center;min-height:38px;border-radius:999px;\
10399                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
10400                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
10401                     .theme-toggle .icon-sun{{display:none;}}\
10402                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
10403                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
10404                     .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
10405                     display:flex;align-items:center;justify-content:center;\
10406                     min-height:calc(100vh - 56px);}}\
10407                     @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
10408                     .panel{{background:var(--surface);border:1px solid var(--line);\
10409                     border-radius:var(--radius);box-shadow:var(--shadow);\
10410                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
10411                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
10412                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
10413                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
10414                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
10415                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
10416                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
10417                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
10418                     min-height:42px;padding:0 20px;border-radius:14px;\
10419                     border:1px solid var(--line-strong);text-decoration:none;\
10420                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
10421                     .back-link:hover{{background:var(--line);}}\
10422                     </style></head>\
10423                     <body>\
10424                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
10425                       <a class=\"brand\" href=\"/\">\
10426                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
10427                         <div class=\"brand-copy\">\
10428                           <div class=\"brand-title\">OxideSLOC</div>\
10429                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
10430                         </div>\
10431                       </a>\
10432                       <div class=\"nav-right\">\
10433                         <a class=\"nav-pill\" href=\"/\">Home</a>\
10434                         <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
10435                         <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
10436                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
10437                           <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>\
10438                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
10439                           <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>\
10440                         </button>\
10441                       </div>\
10442                     </div></div>\
10443                     <div class=\"page\"><div class=\"panel\">\
10444                       <div class=\"spin-ring\"></div>\
10445                       <h1>Generating PDF\u{2026}</h1>\
10446                       <p>The PDF is being generated from the scan results.<br>\
10447                       This page refreshes automatically \u{2014} usually a few seconds.</p>\
10448                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
10449                     </div></div>\
10450                     <script nonce=\"{csp_nonce}\">\
10451                     (function(){{\
10452                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
10453                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
10454                       var t=document.getElementById(\"theme-toggle\");\
10455                       if(t)t.addEventListener(\"click\",function(){{\
10456                         var d=b.classList.toggle(\"dark-theme\");\
10457                         localStorage.setItem(k,d?\"dark\":\"light\");\
10458                       }});\
10459                     }})();\
10460                     </script>\
10461                     </body></html>"
10462    );
10463    Html(html).into_response()
10464}
10465
10466/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
10467fn render_error_artifact_html(
10468    message: String,
10469    last_report_url: Option<String>,
10470    last_report_label: Option<String>,
10471    run_id: Option<String>,
10472    error_code: Option<u16>,
10473    csp_nonce: &str,
10474) -> String {
10475    ErrorTemplate {
10476        message,
10477        last_report_url,
10478        last_report_label,
10479        run_id,
10480        error_code,
10481        csp_nonce: csp_nonce.to_owned(),
10482        version: env!("CARGO_PKG_VERSION"),
10483    }
10484    .render()
10485    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
10486}
10487
10488/// Read a file and serve it as an attachment download.
10489fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
10490    fs::read(path).map_or_else(
10491        |_| StatusCode::NOT_FOUND.into_response(),
10492        |bytes| {
10493            let filename = path.file_name().map_or_else(
10494                || fallback_filename.to_string(),
10495                |n| n.to_string_lossy().into_owned(),
10496            );
10497            (
10498                [
10499                    (header::CONTENT_TYPE, content_type.to_string()),
10500                    (
10501                        header::CONTENT_DISPOSITION,
10502                        format!("attachment; filename=\"{filename}\""),
10503                    ),
10504                ],
10505                bytes,
10506            )
10507                .into_response()
10508        },
10509    )
10510}
10511
10512fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
10513    let Some(path) = csv_path else {
10514        let html = render_error_artifact_html(
10515            "CSV report was not generated for this run, or was not recorded in \
10516             the scan registry."
10517                .to_string(),
10518            Some(format!("/runs/html/{run_id}")),
10519            Some("View HTML Report".to_string()),
10520            Some(run_id.to_string()),
10521            Some(404),
10522            csp_nonce,
10523        );
10524        return (StatusCode::NOT_FOUND, Html(html)).into_response();
10525    };
10526    serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
10527}
10528
10529fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
10530    let Some(path) = xlsx_path else {
10531        let html = render_error_artifact_html(
10532            "Excel report was not generated for this run, or was not recorded in \
10533             the scan registry."
10534                .to_string(),
10535            Some(format!("/runs/html/{run_id}")),
10536            Some("View HTML Report".to_string()),
10537            Some(run_id.to_string()),
10538            Some(404),
10539            csp_nonce,
10540        );
10541        return (StatusCode::NOT_FOUND, Html(html)).into_response();
10542    };
10543    serve_binary_download(
10544        &path,
10545        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10546        "report.xlsx",
10547    )
10548}
10549
10550fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
10551    let path = artifact_set
10552        .scan_config_path
10553        .as_deref()
10554        .map(std::path::Path::to_path_buf)
10555        .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
10556        .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
10557    fs::read(&path).map_or_else(
10558        |_| StatusCode::NOT_FOUND.into_response(),
10559        |bytes| {
10560            (
10561                [
10562                    (
10563                        header::CONTENT_TYPE,
10564                        "application/json; charset=utf-8".to_string(),
10565                    ),
10566                    (
10567                        header::CONTENT_DISPOSITION,
10568                        "attachment; filename=\"scan-config.json\"".to_string(),
10569                    ),
10570                ],
10571                bytes,
10572            )
10573                .into_response()
10574        },
10575    )
10576}
10577
10578/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
10579/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
10580/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
10581/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
10582async fn serve_submodule_pdf_arm(
10583    artifact: &str,
10584    artifact_set: RunArtifacts,
10585    wants_download: bool,
10586    run_id: &str,
10587    csp_nonce: &str,
10588) -> Response {
10589    // "sub_benchmark_pdf" → base = "sub_benchmark"
10590    let base = artifact.trim_end_matches("_pdf");
10591    let sub_dir = artifact_set.output_dir.join("submodules");
10592    let pdf_path = sub_dir.join(format!("{base}.pdf"));
10593
10594    if !pdf_path.exists() {
10595        // On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
10596        let derived_safe = base.trim_start_matches("sub_");
10597        let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
10598            let parent_run = read_json(jp).ok()?;
10599            let sub = parent_run
10600                .submodule_summaries
10601                .iter()
10602                .find(|s| sanitize_project_label(&s.name) == derived_safe)?
10603                .clone();
10604            let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
10605            Some((parent_run, sub, parent_path))
10606        });
10607
10608        if let Some((parent_run, sub, parent_path)) = rebuilt {
10609            let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
10610            let pp = pdf_path.clone();
10611            let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
10612        }
10613    }
10614
10615    if !pdf_path.exists() {
10616        let html = render_error_artifact_html(
10617            "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
10618             enabled."
10619                .to_string(),
10620            Some("/view-reports".to_string()),
10621            Some("View Reports".to_string()),
10622            Some(run_id.to_string()),
10623            Some(404),
10624            csp_nonce,
10625        );
10626        return (StatusCode::NOT_FOUND, Html(html)).into_response();
10627    }
10628
10629    serve_pdf_artifact(
10630        &pdf_path,
10631        &artifact_set.report_title,
10632        run_id,
10633        wants_download,
10634        csp_nonce,
10635    )
10636}
10637
10638fn serve_submodule_arm(
10639    artifact: &str,
10640    artifact_set: &RunArtifacts,
10641    wants_download: bool,
10642    csp_nonce: &str,
10643    run_id: &str,
10644    server_mode: bool,
10645) -> Response {
10646    if artifact.len() > 128
10647        || !artifact
10648            .chars()
10649            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
10650    {
10651        return StatusCode::BAD_REQUEST.into_response();
10652    }
10653    let filename = format!("{artifact}.html");
10654    // Check submodules/ subfolder first (new layout), fall back to root (old layout).
10655    let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
10656    let path = if new_layout.exists() {
10657        new_layout
10658    } else {
10659        artifact_set.output_dir.join(&filename)
10660    };
10661    if !path.exists() {
10662        let html = render_error_artifact_html(
10663            format!(
10664                "Sub-report '{artifact}' was not found in the run directory.\n\
10665                 Re-run the analysis with 'Detect and separate git submodules' \
10666                 and HTML output enabled."
10667            ),
10668            Some("/view-reports".to_string()),
10669            Some("View Reports".to_string()),
10670            Some(run_id.to_string()),
10671            Some(404),
10672            csp_nonce,
10673        );
10674        return (StatusCode::NOT_FOUND, Html(html)).into_response();
10675    }
10676    serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
10677}
10678
10679async fn serve_pdf_arm(
10680    state: &AppState,
10681    artifact_set: RunArtifacts,
10682    wants_download: bool,
10683    run_id: &str,
10684    csp_nonce: &str,
10685) -> Response {
10686    let report_title = artifact_set.report_title.clone();
10687    let had_pdf_in_registry = artifact_set.pdf_path.is_some();
10688    let stale_html_name = artifact_set
10689        .html_path
10690        .as_deref()
10691        .and_then(|p| p.file_name())
10692        .map(|n| n.to_string_lossy().into_owned());
10693    let path = match resolve_or_queue_pdf(
10694        state,
10695        artifact_set.pdf_path,
10696        artifact_set.json_path.clone(),
10697        artifact_set.output_dir.clone(),
10698        run_id,
10699        &report_title,
10700        csp_nonce,
10701    )
10702    .await
10703    {
10704        Ok(p) => p,
10705        Err(r) => return r,
10706    };
10707    if !path.exists() {
10708        // Distinguish a stale registry path (folder moved) from an in-progress
10709        // background generation. Only show the locate page when the PDF was
10710        // already recorded in the registry but the file is now missing.
10711        if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
10712            let html = LocateFileTemplate {
10713                run_id: run_id.to_string(),
10714                artifact_type: "pdf".to_string(),
10715                expected_filename,
10716                server_mode: state.server_mode,
10717                csp_nonce: csp_nonce.to_string(),
10718                version: env!("CARGO_PKG_VERSION"),
10719            }
10720            .render()
10721            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10722            return (StatusCode::NOT_FOUND, Html(html)).into_response();
10723        }
10724        return pdf_generating_response(run_id, csp_nonce);
10725    }
10726    serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
10727}
10728
10729async fn artifact_handler(
10730    State(state): State<AppState>,
10731    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10732    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
10733    Query(query): Query<ArtifactQuery>,
10734) -> Response {
10735    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
10736        Ok(a) => a,
10737        Err(r) => return r,
10738    };
10739
10740    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
10741
10742    match artifact.as_str() {
10743        "html" => {
10744            let Some(path) = artifact_set.html_path else {
10745                return StatusCode::NOT_FOUND.into_response();
10746            };
10747            serve_html_artifact(
10748                &path,
10749                wants_download,
10750                &csp_nonce,
10751                &run_id,
10752                state.server_mode,
10753            )
10754        }
10755        "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
10756        "json" => {
10757            let Some(path) = artifact_set.json_path else {
10758                let html = render_error_artifact_html(
10759                    "JSON result was not generated for this run, or was not recorded in \
10760                     the scan registry. Re-run the analysis with JSON output enabled."
10761                        .to_string(),
10762                    Some("/view-reports".to_string()),
10763                    Some("View Reports".to_string()),
10764                    Some(run_id.clone()),
10765                    Some(404),
10766                    &csp_nonce,
10767                );
10768                return (StatusCode::NOT_FOUND, Html(html)).into_response();
10769            };
10770            serve_json_artifact(&path, wants_download, &csp_nonce)
10771        }
10772        "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
10773        "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
10774        "scan-config" => serve_scan_config_arm(&artifact_set),
10775        _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
10776            serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
10777                .await
10778        }
10779        _ if artifact.starts_with("sub_") => serve_submodule_arm(
10780            &artifact,
10781            &artifact_set,
10782            wants_download,
10783            &csp_nonce,
10784            &run_id,
10785            state.server_mode,
10786        ),
10787        _ => StatusCode::NOT_FOUND.into_response(),
10788    }
10789}
10790
10791// ── History ───────────────────────────────────────────────────────────────────
10792
10793struct SubmoduleLinkRow {
10794    name: String,
10795    url: String,
10796}
10797
10798struct HistoryEntryRow {
10799    run_id: String,
10800    run_id_short: String,
10801    timestamp: String,
10802    timestamp_utc_ms: i64,
10803    project_label: String,
10804    project_path: String,
10805    files_analyzed: u64,
10806    files_skipped: u64,
10807    code_lines: u64,
10808    comment_lines: u64,
10809    blank_lines: u64,
10810    total_physical_lines: u64,
10811    functions: u64,
10812    classes: u64,
10813    variables: u64,
10814    imports: u64,
10815    test_count: u64,
10816    git_branch: String,
10817    git_commit: String,
10818    /// Full-length commit SHA shown as a hover tooltip (falls back to short when absent).
10819    git_commit_long: String,
10820    /// Who/what produced this report: CI system name, or `user / host` (see
10821    /// `RegistryEntry::performed_by`). Lets pooled reports from different environments be
10822    /// told apart in the list.
10823    performed_by: String,
10824    /// Operating system the scan ran on (shown as a tooltip on the environment cell).
10825    scan_os: String,
10826    has_html: bool,
10827    has_json: bool,
10828    has_pdf: bool,
10829    submodule_links: Vec<SubmoduleLinkRow>,
10830    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
10831    submodule_names_csv: String,
10832}
10833
10834/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
10835fn nth_weekday_of_month(
10836    year: i32,
10837    month: u32,
10838    weekday: chrono::Weekday,
10839    n: u32,
10840) -> chrono::NaiveDate {
10841    use chrono::Datelike;
10842    let mut count = 0u32;
10843    let mut day = 1u32;
10844    loop {
10845        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
10846        if d.weekday() == weekday {
10847            count += 1;
10848            if count == n {
10849                return d;
10850            }
10851        }
10852        day += 1;
10853    }
10854}
10855
10856/// Returns true if `dt` falls within US Pacific Daylight Time.
10857/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
10858/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
10859fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
10860    use chrono::{Datelike, TimeZone};
10861    let year = dt.year();
10862    let dst_start = chrono::Utc.from_utc_datetime(
10863        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
10864            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
10865    );
10866    let dst_end = chrono::Utc.from_utc_datetime(
10867        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
10868            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
10869    );
10870    dt >= dst_start && dt < dst_end
10871}
10872
10873fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
10874    if is_pacific_dst(dt) {
10875        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
10876            .format("%Y-%m-%d %H:%M PDT")
10877            .to_string()
10878    } else {
10879        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
10880            .format("%Y-%m-%d %H:%M PST")
10881            .to_string()
10882    }
10883}
10884
10885/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
10886fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
10887    let (offset, tz) = if is_pacific_dst(dt) {
10888        (
10889            chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
10890            "PDT",
10891        )
10892    } else {
10893        (
10894            chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
10895            "PST",
10896        )
10897    };
10898    format!(
10899        "{} {tz}",
10900        dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
10901    )
10902}
10903
10904fn fmt_git_date(iso: &str) -> Option<String> {
10905    chrono::DateTime::parse_from_rfc3339(iso)
10906        .ok()
10907        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
10908}
10909
10910/// Recover the full-length commit SHA for a registry entry whose stored record
10911/// predates the `git_commit_long` field, by scanning the tail of its result JSON.
10912///
10913/// Result JSONs can be very large (100 MB+ for big repos), but the git metadata
10914/// is serialized after the per-file records, near the end of the file. We read a
10915/// bounded tail and pick the `git_commit_long` value whose hash begins with the
10916/// known short SHA — this disambiguates the super-repo commit from any submodule
10917/// commits that also appear. Returns `None` if the file is unreadable or no match.
10918fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
10919    use std::io::{Read, Seek, SeekFrom};
10920    const TAIL: u64 = 4 * 1024 * 1024; // 4 MiB is ample to cover the git metadata block
10921    if short.is_empty() {
10922        return None;
10923    }
10924    let len = std::fs::metadata(path).ok()?.len();
10925    let start = len.saturating_sub(TAIL);
10926    let mut file = std::fs::File::open(path).ok()?;
10927    file.seek(SeekFrom::Start(start)).ok()?;
10928    let mut buf = Vec::new();
10929    file.read_to_end(&mut buf).ok()?;
10930    let text = String::from_utf8_lossy(&buf);
10931    let short_lower = short.to_ascii_lowercase();
10932    let key = "\"git_commit_long\"";
10933    let mut found: Option<String> = None;
10934    let mut cursor = 0usize;
10935    while let Some(idx) = text[cursor..].find(key) {
10936        let after_key = cursor + idx + key.len();
10937        cursor = after_key;
10938        let rest = &text[after_key..];
10939        let Some(colon) = rest.find(':') else { break };
10940        let value_region = rest[colon + 1..].trim_start();
10941        // Skip `null` (or any non-string) values without consuming the next field.
10942        if let Some(open) = value_region.strip_prefix('"')
10943            && let Some(close) = open.find('"')
10944        {
10945            let val = &open[..close];
10946            if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
10947                found = Some(val.to_string());
10948            }
10949        }
10950    }
10951    found
10952}
10953
10954fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
10955    reg.entries
10956        .iter()
10957        .map(|e| {
10958            let submodule_links = {
10959                let mut links: Vec<SubmoduleLinkRow> = vec![];
10960                let sub_dir = e
10961                    .html_path
10962                    .as_ref()
10963                    .and_then(|p| p.parent())
10964                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
10965                if let Some(dir) = sub_dir
10966                    && let Ok(rd) = std::fs::read_dir(dir)
10967                {
10968                    for entry_res in rd.flatten() {
10969                        let fname = entry_res.file_name();
10970                        let fname_str = fname.to_string_lossy();
10971                        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
10972                            let stem = &fname_str[..fname_str.len() - 5];
10973                            let display = stem[4..].replace('-', " ");
10974                            links.push(SubmoduleLinkRow {
10975                                name: display,
10976                                url: format!("/runs/{stem}/{}", e.run_id),
10977                            });
10978                        }
10979                    }
10980                }
10981                links.sort_by(|a, b| a.name.cmp(&b.name));
10982                links
10983            };
10984            let submodule_names_csv = submodule_links
10985                .iter()
10986                .map(|l| l.name.as_str())
10987                .collect::<Vec<_>>()
10988                .join(",");
10989            HistoryEntryRow {
10990                run_id: e.run_id.clone(),
10991                run_id_short: e
10992                    .run_id
10993                    .split('-')
10994                    .next_back()
10995                    .unwrap_or(&e.run_id)
10996                    .chars()
10997                    .take(7)
10998                    .collect(),
10999                timestamp: fmt_la_time(e.timestamp_utc),
11000                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
11001                project_label: e.project_label.clone(),
11002                project_path: e
11003                    .input_roots
11004                    .first()
11005                    .map(|s| sanitize_path_str(s))
11006                    .unwrap_or_default(),
11007                files_analyzed: e.summary.files_analyzed,
11008                files_skipped: e.summary.files_skipped,
11009                code_lines: e.summary.code_lines,
11010                comment_lines: e.summary.comment_lines,
11011                blank_lines: e.summary.blank_lines,
11012                total_physical_lines: e.summary.total_physical_lines,
11013                functions: e.summary.functions,
11014                classes: e.summary.classes,
11015                variables: e.summary.variables,
11016                imports: e.summary.imports,
11017                test_count: e.summary.test_count,
11018                git_branch: e.git_branch.clone().unwrap_or_default(),
11019                git_commit: e.git_commit.clone().unwrap_or_default(),
11020                git_commit_long: {
11021                    let short = e.git_commit.clone().unwrap_or_default();
11022                    e.git_commit_long
11023                        .clone()
11024                        .filter(|s| !s.is_empty())
11025                        .or_else(|| {
11026                            e.json_path
11027                                .as_ref()
11028                                .and_then(|p| extract_long_commit_from_json(p, &short))
11029                        })
11030                        .unwrap_or(short)
11031                },
11032                performed_by: e.performed_by(),
11033                scan_os: e.scan_os.clone().unwrap_or_default(),
11034                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
11035                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
11036                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
11037                submodule_links,
11038                submodule_names_csv,
11039            }
11040        })
11041        .collect()
11042}
11043
11044#[derive(Deserialize, Default)]
11045struct HistoryQuery {
11046    linked: Option<String>,
11047    error: Option<String>,
11048}
11049
11050async fn history_handler(
11051    State(state): State<AppState>,
11052    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11053    Query(query): Query<HistoryQuery>,
11054) -> impl IntoResponse {
11055    // Auto-scan all watched directories before rendering so the list stays fresh.
11056    auto_scan_watched_dirs(&state).await;
11057    let watched_dirs: Vec<String> = {
11058        let wd = state.watched_dirs.lock().await;
11059        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11060    };
11061    let mut entries = {
11062        let reg = state.registry.lock().await;
11063        make_history_rows(&reg)
11064    };
11065    entries.retain(|e| e.has_html);
11066    let total_scans = entries.len();
11067    let linked_count = query
11068        .linked
11069        .as_deref()
11070        .and_then(|s| s.parse::<usize>().ok())
11071        .unwrap_or(0);
11072    let browse_error = query.error.filter(|s| !s.is_empty());
11073    let template = HistoryTemplate {
11074        version: env!("CARGO_PKG_VERSION"),
11075        entries,
11076        total_scans,
11077        linked_count,
11078        browse_error,
11079        watched_dirs,
11080        csp_nonce,
11081        server_mode: state.server_mode,
11082    };
11083    Html(
11084        template
11085            .render()
11086            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
11087    )
11088    .into_response()
11089}
11090
11091async fn compare_select_handler(
11092    State(state): State<AppState>,
11093    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11094) -> impl IntoResponse {
11095    auto_scan_watched_dirs(&state).await;
11096    let watched_dirs: Vec<String> = {
11097        let wd = state.watched_dirs.lock().await;
11098        wd.dirs.iter().map(|p| p.display().to_string()).collect()
11099    };
11100    let mut entries = {
11101        let reg = state.registry.lock().await;
11102        make_history_rows(&reg)
11103    };
11104    entries.retain(|e| e.has_json);
11105    let total_scans = entries.len();
11106    let template = CompareSelectTemplate {
11107        version: env!("CARGO_PKG_VERSION"),
11108        entries,
11109        total_scans,
11110        watched_dirs,
11111        csp_nonce,
11112        server_mode: state.server_mode,
11113    };
11114    Html(
11115        template
11116            .render()
11117            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
11118    )
11119    .into_response()
11120}
11121
11122// ── Compare ───────────────────────────────────────────────────────────────────
11123
11124#[derive(Deserialize, Default)]
11125struct CompareQuery {
11126    a: Option<String>,
11127    b: Option<String>,
11128    /// Optional submodule name to scope the comparison to one submodule.
11129    sub: Option<String>,
11130    /// "super" to exclude all submodule files and show only the super-repo.
11131    scope: Option<String>,
11132}
11133
11134struct CompareFileDeltaRow {
11135    relative_path: String,
11136    language: String,
11137    status: String,
11138    baseline_code: i64,
11139    current_code: i64,
11140    baseline_code_display: String,
11141    current_code_display: String,
11142    code_delta_str: String,
11143    code_delta_class: String,
11144    comment_delta_str: String,
11145    comment_delta_class: String,
11146    total_delta_str: String,
11147    total_delta_class: String,
11148}
11149
11150/// Recompute `summary_totals` from the current `per_file_records` slice.
11151/// Used when `per_file_records` has been narrowed to a submodule subset.
11152fn recompute_summary_from_records(run: &mut AnalysisRun) {
11153    let mut totals = SummaryTotals::default();
11154    for r in &run.per_file_records {
11155        if r.language.is_some() {
11156            totals.files_analyzed += 1;
11157        }
11158        totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
11159        totals.code_lines += r.effective_counts.code_lines;
11160        totals.comment_lines += r.effective_counts.comment_lines;
11161        totals.blank_lines += r.effective_counts.blank_lines;
11162        totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
11163        totals.functions += r.raw_line_categories.functions;
11164        totals.classes += r.raw_line_categories.classes;
11165        totals.variables += r.raw_line_categories.variables;
11166        totals.imports += r.raw_line_categories.imports;
11167        totals.test_count += r.raw_line_categories.test_count;
11168        totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
11169        totals.test_suite_count += r.raw_line_categories.test_suite_count;
11170        if let Some(cov) = &r.coverage {
11171            totals.coverage_lines_found += u64::from(cov.lines_found);
11172            totals.coverage_lines_hit += u64::from(cov.lines_hit);
11173            totals.coverage_functions_found += u64::from(cov.functions_found);
11174            totals.coverage_functions_hit += u64::from(cov.functions_hit);
11175            totals.coverage_branches_found += u64::from(cov.branches_found);
11176            totals.coverage_branches_hit += u64::from(cov.branches_hit);
11177        }
11178    }
11179    totals.files_considered = totals.files_analyzed;
11180    run.summary_totals = totals;
11181}
11182
11183fn fmt_delta(n: i64) -> String {
11184    if n > 0 {
11185        format!("+{n}")
11186    } else {
11187        format!("{n}")
11188    }
11189}
11190
11191fn delta_class(n: i64) -> &'static str {
11192    use std::cmp::Ordering;
11193    match n.cmp(&0) {
11194        Ordering::Greater => "pos",
11195        Ordering::Less => "neg",
11196        Ordering::Equal => "zero",
11197    }
11198}
11199
11200// ratio/percentage display, precision loss acceptable
11201#[allow(clippy::cast_precision_loss)]
11202fn fmt_pct(delta: i64, baseline: u64) -> String {
11203    if baseline == 0 {
11204        return "—".to_string();
11205    }
11206    #[allow(clippy::cast_precision_loss)]
11207    let pct = (delta as f64 / baseline as f64) * 100.0;
11208    if pct > 0.049 {
11209        format!("+{pct:.1}%")
11210    } else if pct < -0.049 {
11211        format!("{pct:.1}%")
11212    } else {
11213        "±0%".to_string()
11214    }
11215}
11216
11217/// Returns (`display_string`, `css_class`) for a numeric change column cell.
11218fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
11219    prev.map_or_else(
11220        || ("—".to_string(), "na"),
11221        |p| {
11222            #[allow(clippy::cast_possible_wrap)]
11223            let d = curr as i64 - p as i64;
11224            (fmt_delta(d), delta_class(d))
11225        },
11226    )
11227}
11228
11229#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
11230fn load_scan_for_compare(
11231    json_path: &std::path::Path,
11232    scan_label: &str,
11233    run_id: &str,
11234    server_mode: bool,
11235    compare_url: &str,
11236    csp_nonce: &str,
11237) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
11238    match read_json(json_path) {
11239        Ok(r) => Ok(r),
11240        Err(e) => {
11241            if server_mode {
11242                let html = ErrorTemplate {
11243                    message: format!(
11244                        "Could not load {scan_label} scan data. The scan output folder may have \
11245                         been moved, renamed, or deleted. Re-running the analysis will create \
11246                         fresh comparison data."
11247                    ),
11248                    last_report_url: Some("/compare-scans".to_string()),
11249                    last_report_label: Some("Compare Scans".to_string()),
11250                    run_id: Some(run_id.to_owned()),
11251                    error_code: Some(404),
11252                    csp_nonce: csp_nonce.to_owned(),
11253                    version: env!("CARGO_PKG_VERSION"),
11254                }
11255                .render()
11256                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
11257                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
11258            }
11259            let msg = format!(
11260                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
11261                json_path.display()
11262            );
11263            let folder_hint = output_folder_hint(json_path);
11264            Err(missing_scan_relocate_response(
11265                &msg,
11266                run_id,
11267                &folder_hint,
11268                compare_url,
11269                false,
11270                csp_nonce,
11271            ))
11272        }
11273    }
11274}
11275
11276struct ChurnStats {
11277    new_scope: bool,
11278    scope_flag: bool,
11279    churn_rate_str: String,
11280    churn_rate_class: String,
11281}
11282
11283fn compute_churn_stats(
11284    baseline_code: u64,
11285    current_code: u64,
11286    lines_added: i64,
11287    lines_removed: i64,
11288) -> ChurnStats {
11289    let new_scope = baseline_code == 0 && current_code > 0;
11290    #[allow(clippy::cast_precision_loss)]
11291    let churn_pct = if baseline_code > 0 {
11292        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
11293    } else {
11294        0.0
11295    };
11296    #[allow(clippy::cast_precision_loss)]
11297    let scope_flag =
11298        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
11299    let churn_rate_str = if new_scope {
11300        "New".to_string()
11301    } else if baseline_code > 0 {
11302        format!("{churn_pct:.1}%")
11303    } else {
11304        "—".to_string()
11305    };
11306    let churn_rate_class = if new_scope || churn_pct > 20.0 {
11307        "high".to_string()
11308    } else if churn_pct > 5.0 {
11309        "med".to_string()
11310    } else {
11311        "low".to_string()
11312    };
11313    ChurnStats {
11314        new_scope,
11315        scope_flag,
11316        churn_rate_str,
11317        churn_rate_class,
11318    }
11319}
11320
11321/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
11322/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
11323/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
11324fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
11325    let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
11326    if !has_data {
11327        return String::new();
11328    }
11329    let base_str = s
11330        .baseline_coverage_line_pct
11331        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
11332    let curr_str = s
11333        .current_coverage_line_pct
11334        .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
11335    let (delta_str, cls) = match s.coverage_line_pct_delta {
11336        Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
11337        Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
11338        Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
11339        None => ("\u{2014}".into(), "zero"),
11340    };
11341    format!(
11342        r#"<div class="delta-card">
11343          <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>
11344          <div class="delta-card-label">Line coverage</div>
11345          <div class="delta-card-from">Before: {base_str}</div>
11346          <div class="delta-card-to">{curr_str}</div>
11347          <span class="delta-card-change {cls}">{delta_str}</span>
11348        </div>"#
11349    )
11350}
11351
11352/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
11353#[allow(clippy::ref_option)]
11354fn narrow_run_pair_by_scope(
11355    mut baseline: AnalysisRun,
11356    mut current: AnalysisRun,
11357    active_sub: &Option<String>,
11358    super_scope: bool,
11359) -> (AnalysisRun, AnalysisRun) {
11360    if let Some(sub_name) = active_sub {
11361        baseline
11362            .per_file_records
11363            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
11364        current
11365            .per_file_records
11366            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
11367        recompute_summary_from_records(&mut baseline);
11368        recompute_summary_from_records(&mut current);
11369    } else if super_scope {
11370        baseline.per_file_records.retain(|f| f.submodule.is_none());
11371        current.per_file_records.retain(|f| f.submodule.is_none());
11372        recompute_summary_from_records(&mut baseline);
11373        recompute_summary_from_records(&mut current);
11374    }
11375    (baseline, current)
11376}
11377
11378/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
11379#[allow(clippy::ref_option)]
11380fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
11381    if let Some(sub_name) = active_sub {
11382        for run in runs.iter_mut() {
11383            run.per_file_records
11384                .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
11385            recompute_summary_from_records(run);
11386        }
11387    } else if super_scope {
11388        for run in runs.iter_mut() {
11389            run.per_file_records.retain(|f| f.submodule.is_none());
11390            recompute_summary_from_records(run);
11391        }
11392    }
11393}
11394
11395#[allow(clippy::too_many_lines)]
11396async fn compare_handler(
11397    State(state): State<AppState>,
11398    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11399    Query(query): Query<CompareQuery>,
11400) -> impl IntoResponse {
11401    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
11402    // redirect to the history page where the user can select two runs.
11403    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
11404        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
11405        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
11406    };
11407
11408    let (maybe_a, maybe_b) = {
11409        let reg = state.registry.lock().await;
11410        (
11411            reg.find_by_run_id(&run_id_a).cloned(),
11412            reg.find_by_run_id(&run_id_b).cloned(),
11413        )
11414    };
11415
11416    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
11417        let html = ErrorTemplate {
11418            message: "One or both run IDs were not found in scan history. \
11419                      The runs may have been deleted or the registry may have been reset."
11420                .to_string(),
11421            last_report_url: Some("/compare-scans".to_string()),
11422            last_report_label: Some("Compare Scans".to_string()),
11423            run_id: None,
11424            error_code: None,
11425            csp_nonce: csp_nonce.clone(),
11426            version: env!("CARGO_PKG_VERSION"),
11427        }
11428        .render()
11429        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
11430        return Html(html).into_response();
11431    };
11432
11433    // Ensure older scan is always the baseline.
11434    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
11435        (entry_a, entry_b)
11436    } else {
11437        (entry_b, entry_a)
11438    };
11439
11440    // If query params were in the wrong order, redirect to canonical URL so the
11441    // browser always shows the same URL for the same two scans regardless of how
11442    // the user arrived here (Full diff button vs. Compare Scans selection).
11443    if baseline_entry.run_id != run_id_a {
11444        let canonical = format!(
11445            "/compare?a={}&b={}",
11446            baseline_entry.run_id, current_entry.run_id
11447        );
11448        return axum::response::Redirect::to(&canonical).into_response();
11449    }
11450
11451    let (Some(base_json), Some(curr_json)) = (
11452        baseline_entry.json_path.as_ref(),
11453        current_entry.json_path.as_ref(),
11454    ) else {
11455        let html = ErrorTemplate {
11456            message: "Full comparison requires JSON scan data, which was not saved for one or \
11457                      both of these runs. JSON is now always saved for new scans — re-run the \
11458                      affected projects to enable comparisons."
11459                .to_string(),
11460            last_report_url: Some("/compare-scans".to_string()),
11461            last_report_label: Some("Compare Scans".to_string()),
11462            run_id: None,
11463            error_code: None,
11464            csp_nonce: csp_nonce.clone(),
11465            version: env!("CARGO_PKG_VERSION"),
11466        }
11467        .render()
11468        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
11469        return Html(html).into_response();
11470    };
11471
11472    let compare_url = format!(
11473        "/compare?a={}&b={}",
11474        baseline_entry.run_id, current_entry.run_id
11475    );
11476
11477    let baseline_run = match load_scan_for_compare(
11478        base_json,
11479        "baseline",
11480        &baseline_entry.run_id,
11481        state.server_mode,
11482        &compare_url,
11483        &csp_nonce,
11484    ) {
11485        Ok(r) => r,
11486        Err(resp) => return resp,
11487    };
11488    let current_run = match load_scan_for_compare(
11489        curr_json,
11490        "current",
11491        &current_entry.run_id,
11492        state.server_mode,
11493        &compare_url,
11494        &csp_nonce,
11495    ) {
11496        Ok(r) => r,
11497        Err(resp) => return resp,
11498    };
11499
11500    let active_submodule = query.sub.clone();
11501    let super_scope_active = query.scope.as_deref() == Some("super");
11502
11503    let submodule_options = baseline_run
11504        .submodule_summaries
11505        .iter()
11506        .chain(current_run.submodule_summaries.iter())
11507        .map(|s| s.name.clone())
11508        .collect::<std::collections::BTreeSet<_>>()
11509        .into_iter()
11510        .collect::<Vec<_>>();
11511    let has_any_submodule_data = !submodule_options.is_empty();
11512
11513    // Narrow per_file_records when a scope is active, then recompute totals.
11514    let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
11515        baseline_run,
11516        current_run,
11517        &active_submodule,
11518        super_scope_active,
11519    );
11520
11521    let comparison = compute_delta(&effective_baseline, &effective_current);
11522
11523    let file_rows: Vec<CompareFileDeltaRow> = comparison
11524        .file_deltas
11525        .iter()
11526        .map(|d| CompareFileDeltaRow {
11527            relative_path: d.relative_path.clone(),
11528            language: d.language.clone().unwrap_or_else(|| "—".into()),
11529            status: match d.status {
11530                FileChangeStatus::Added => "added".into(),
11531                FileChangeStatus::Removed => "removed".into(),
11532                FileChangeStatus::Modified => "modified".into(),
11533                FileChangeStatus::Unchanged => "unchanged".into(),
11534            },
11535            baseline_code: d.baseline_code,
11536            current_code: d.current_code,
11537            baseline_code_display: if d.status == FileChangeStatus::Added {
11538                "—".into()
11539            } else {
11540                d.baseline_code.to_string()
11541            },
11542            current_code_display: if d.status == FileChangeStatus::Removed {
11543                "—".into()
11544            } else {
11545                d.current_code.to_string()
11546            },
11547            code_delta_str: fmt_delta(d.code_delta),
11548            code_delta_class: delta_class(d.code_delta).into(),
11549            comment_delta_str: fmt_delta(d.comment_delta),
11550            comment_delta_class: delta_class(d.comment_delta).into(),
11551            total_delta_str: fmt_delta(d.total_delta),
11552            total_delta_class: delta_class(d.total_delta).into(),
11553        })
11554        .collect();
11555
11556    let project_path = baseline_entry
11557        .input_roots
11558        .first()
11559        .map(|s| sanitize_path_str(s))
11560        .unwrap_or_default();
11561    let lines_added = sum_added_code_lines(&comparison);
11562    let lines_removed = sum_removed_code_lines(&comparison);
11563    let churn = compute_churn_stats(
11564        comparison.summary.baseline_code,
11565        comparison.summary.current_code,
11566        lines_added,
11567        lines_removed,
11568    );
11569    let s = &comparison.summary;
11570    let template = CompareTemplate {
11571        loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
11572        version: env!("CARGO_PKG_VERSION"),
11573        project_label: baseline_entry.project_label.clone(),
11574        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
11575        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
11576        baseline_run_id: baseline_entry.run_id.clone(),
11577        current_run_id: current_entry.run_id.clone(),
11578        baseline_run_id_short: baseline_entry
11579            .run_id
11580            .split('-')
11581            .next_back()
11582            .unwrap_or(&baseline_entry.run_id)
11583            .chars()
11584            .take(7)
11585            .collect(),
11586        current_run_id_short: current_entry
11587            .run_id
11588            .split('-')
11589            .next_back()
11590            .unwrap_or(&current_entry.run_id)
11591            .chars()
11592            .take(7)
11593            .collect(),
11594        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
11595        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
11596        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
11597        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
11598        project_path: project_path.clone(),
11599        baseline_code: s.baseline_code,
11600        current_code: s.current_code,
11601        code_lines_delta_str: fmt_delta(s.code_lines_delta),
11602        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
11603        baseline_files: s.baseline_files,
11604        current_files: s.current_files,
11605        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
11606        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
11607        baseline_comments: s.baseline_comments,
11608        current_comments: s.current_comments,
11609        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
11610        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
11611        baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
11612        current_code_fmt: fmt_comma(s.current_code.cast_signed()),
11613        baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
11614        current_files_fmt: fmt_comma(s.current_files.cast_signed()),
11615        baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
11616        current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
11617        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
11618        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
11619        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
11620        code_lines_added: lines_added,
11621        code_lines_removed: lines_removed,
11622        code_lines_modified: sum_modified_code_lines(&comparison),
11623        code_lines_unmodified: sum_unmodified_code_lines(&comparison),
11624        code_lines_total: lines_added
11625            + lines_removed
11626            + sum_modified_code_lines(&comparison)
11627            + sum_unmodified_code_lines(&comparison),
11628        new_scope: churn.new_scope,
11629        churn_rate_str: churn.churn_rate_str,
11630        churn_rate_class: churn.churn_rate_class,
11631        scope_flag: churn.scope_flag,
11632        files_added: comparison.files_added,
11633        files_removed: comparison.files_removed,
11634        files_modified: comparison.files_modified,
11635        files_unchanged: comparison.files_unchanged,
11636        files_total: comparison.files_total,
11637        file_rows,
11638        baseline_git_author: baseline_entry.git_author.clone(),
11639        current_git_author: current_entry.git_author.clone(),
11640        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
11641        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
11642        baseline_performed_by: baseline_entry.performed_by(),
11643        current_performed_by: current_entry.performed_by(),
11644        baseline_git_tags: baseline_entry.git_tags.clone(),
11645        current_git_tags: current_entry.git_tags.clone(),
11646        baseline_git_commit_date: baseline_entry
11647            .git_commit_date
11648            .as_deref()
11649            .and_then(fmt_git_date),
11650        current_git_commit_date: current_entry
11651            .git_commit_date
11652            .as_deref()
11653            .and_then(fmt_git_date),
11654        project_name: project_path
11655            .rsplit(['/', '\\'])
11656            .find(|s| !s.is_empty())
11657            .unwrap_or(&project_path)
11658            .to_string(),
11659        submodule_options,
11660        has_any_submodule_data,
11661        active_submodule,
11662        super_scope_active,
11663        toast_assets: sloc_toast_assets(&csp_nonce),
11664        csp_nonce,
11665        coverage_delta_card: build_coverage_delta_card(s),
11666        baseline_test_count: effective_baseline.summary_totals.test_count,
11667        current_test_count: effective_current.summary_totals.test_count,
11668        baseline_coverage_pct: s.baseline_coverage_line_pct,
11669        current_coverage_pct: s.current_coverage_line_pct,
11670    };
11671
11672    Html(
11673        template
11674            .render()
11675            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
11676    )
11677    .into_response()
11678}
11679
11680// ── Badge endpoint ────────────────────────────────────────────────────────────
11681// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
11682// pages, Jira descriptions, etc.
11683//
11684// GET /badge/<metric>?label=<override>&color=<hex>
11685// Metrics: code-lines  files  comment-lines  blank-lines
11686
11687fn format_number(n: u64) -> String {
11688    let s = n.to_string();
11689    let mut out = String::with_capacity(s.len() + s.len() / 3);
11690    let len = s.len();
11691    for (i, c) in s.chars().enumerate() {
11692        if i > 0 && (len - i).is_multiple_of(3) {
11693            out.push(',');
11694        }
11695        out.push(c);
11696    }
11697    out
11698}
11699
11700const fn badge_char_width(c: char) -> f64 {
11701    match c {
11702        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
11703        'm' | 'w' => 9.0,
11704        ' ' => 4.0,
11705        _ => 6.5,
11706    }
11707}
11708
11709#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
11710fn badge_text_px(text: &str) -> u32 {
11711    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
11712}
11713
11714fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
11715    let lw = badge_text_px(label) + 20;
11716    let rw = badge_text_px(value) + 20;
11717    let total = lw + rw;
11718    let lx = lw / 2;
11719    let rx = lw + rw / 2;
11720    let le = escape_html(label);
11721    let ve = escape_html(value);
11722    let ce = escape_html(color);
11723    format!(
11724        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
11725  <rect width="{total}" height="20" fill="#555"/>
11726  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
11727  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
11728    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
11729    <text x="{lx}" y="13">{le}</text>
11730    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
11731    <text x="{rx}" y="13">{ve}</text>
11732  </g>
11733</svg>"##
11734    )
11735}
11736
11737#[derive(Deserialize)]
11738struct BadgeQuery {
11739    label: Option<String>,
11740    color: Option<String>,
11741}
11742
11743async fn badge_handler(
11744    State(state): State<AppState>,
11745    AxumPath(metric): AxumPath<String>,
11746    Query(query): Query<BadgeQuery>,
11747) -> Response {
11748    let entry = {
11749        let reg = state.registry.lock().await;
11750        reg.entries.first().cloned()
11751    };
11752
11753    let Some(entry) = entry else {
11754        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
11755        return (
11756            [
11757                (header::CONTENT_TYPE, "image/svg+xml"),
11758                (header::CACHE_CONTROL, "no-cache, max-age=0"),
11759            ],
11760            svg,
11761        )
11762            .into_response();
11763    };
11764
11765    let (default_label, value, default_color) = match metric.as_str() {
11766        "code-lines" => (
11767            "code lines",
11768            format_number(entry.summary.code_lines),
11769            "#4a78ee",
11770        ),
11771        "files" => (
11772            "files analyzed",
11773            format_number(entry.summary.files_analyzed),
11774            "#4a9862",
11775        ),
11776        "comment-lines" => (
11777            "comment lines",
11778            format_number(entry.summary.comment_lines),
11779            "#b35428",
11780        ),
11781        "blank-lines" => (
11782            "blank lines",
11783            format_number(entry.summary.blank_lines),
11784            "#7a5db0",
11785        ),
11786        _ => return StatusCode::NOT_FOUND.into_response(),
11787    };
11788
11789    let label = query.label.as_deref().unwrap_or(default_label);
11790    let color = query.color.as_deref().unwrap_or(default_color);
11791    let svg = render_badge_svg(label, &value, color);
11792
11793    (
11794        [
11795            (header::CONTENT_TYPE, "image/svg+xml"),
11796            (header::CACHE_CONTROL, "no-cache, max-age=0"),
11797        ],
11798        svg,
11799    )
11800        .into_response()
11801}
11802
11803// ── Metrics API ───────────────────────────────────────────────────────────────
11804// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
11805// Confluence automation, Jira webhooks, etc.
11806//
11807// GET /api/metrics/latest
11808// GET /api/metrics/<run_id>
11809
11810#[derive(Serialize)]
11811struct ApiCoverageBlock {
11812    lines_found: u64,
11813    lines_hit: u64,
11814    line_pct: f64,
11815    functions_found: u64,
11816    functions_hit: u64,
11817    function_pct: f64,
11818    branches_found: u64,
11819    branches_hit: u64,
11820    branch_pct: f64,
11821}
11822
11823#[derive(Serialize)]
11824struct ApiMetricsResponse {
11825    run_id: String,
11826    timestamp: String,
11827    project: String,
11828    summary: ApiSummaryPayload,
11829    languages: Vec<ApiLanguageRow>,
11830    #[serde(skip_serializing_if = "Option::is_none")]
11831    coverage: Option<ApiCoverageBlock>,
11832}
11833
11834#[derive(Serialize)]
11835struct ApiSummaryPayload {
11836    files_analyzed: u64,
11837    files_skipped: u64,
11838    code_lines: u64,
11839    comment_lines: u64,
11840    blank_lines: u64,
11841    total_physical_lines: u64,
11842    functions: u64,
11843    classes: u64,
11844    variables: u64,
11845    imports: u64,
11846}
11847
11848#[derive(Serialize)]
11849struct ApiLanguageRow {
11850    name: String,
11851    files: u64,
11852    code_lines: u64,
11853    comment_lines: u64,
11854    blank_lines: u64,
11855    functions: u64,
11856    classes: u64,
11857    variables: u64,
11858    imports: u64,
11859}
11860
11861async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
11862    let entry = {
11863        let reg = state.registry.lock().await;
11864        reg.entries.first().cloned()
11865    };
11866    entry.map_or_else(
11867        || error::not_found("no scans recorded yet"),
11868        |e| build_metrics_response(&e),
11869    )
11870}
11871
11872async fn api_metrics_run_handler(
11873    State(state): State<AppState>,
11874    AxumPath(run_id): AxumPath<String>,
11875) -> Response {
11876    let entry = {
11877        let reg = state.registry.lock().await;
11878        reg.find_by_run_id(&run_id).cloned()
11879    };
11880    entry.map_or_else(
11881        || error::not_found("run not found"),
11882        |e| build_metrics_response(&e),
11883    )
11884}
11885
11886fn build_metrics_response(entry: &RegistryEntry) -> Response {
11887    let languages: Vec<ApiLanguageRow> = entry
11888        .json_path
11889        .as_ref()
11890        .and_then(|p| read_json(p).ok())
11891        .map(|run| {
11892            run.totals_by_language
11893                .iter()
11894                .map(|l| ApiLanguageRow {
11895                    name: l.language.display_name().to_string(),
11896                    files: l.files,
11897                    code_lines: l.code_lines,
11898                    comment_lines: l.comment_lines,
11899                    blank_lines: l.blank_lines,
11900                    functions: l.functions,
11901                    classes: l.classes,
11902                    variables: l.variables,
11903                    imports: l.imports,
11904                })
11905                .collect()
11906        })
11907        .unwrap_or_default();
11908
11909    let s = &entry.summary;
11910    let coverage = if s.coverage_lines_found > 0 {
11911        let pct = |hit: u64, found: u64| -> f64 {
11912            if found == 0 {
11913                0.0
11914            } else {
11915                #[allow(clippy::cast_precision_loss)]
11916                let v = (hit as f64 / found as f64) * 100.0;
11917                (v * 10.0).round() / 10.0
11918            }
11919        };
11920        Some(ApiCoverageBlock {
11921            lines_found: s.coverage_lines_found,
11922            lines_hit: s.coverage_lines_hit,
11923            line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
11924            functions_found: s.coverage_functions_found,
11925            functions_hit: s.coverage_functions_hit,
11926            function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
11927            branches_found: s.coverage_branches_found,
11928            branches_hit: s.coverage_branches_hit,
11929            branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
11930        })
11931    } else {
11932        None
11933    };
11934    Json(ApiMetricsResponse {
11935        run_id: entry.run_id.clone(),
11936        timestamp: entry.timestamp_utc.to_rfc3339(),
11937        project: entry.project_label.clone(),
11938        summary: ApiSummaryPayload {
11939            files_analyzed: s.files_analyzed,
11940            files_skipped: s.files_skipped,
11941            code_lines: s.code_lines,
11942            comment_lines: s.comment_lines,
11943            blank_lines: s.blank_lines,
11944            total_physical_lines: s.total_physical_lines,
11945            functions: s.functions,
11946            classes: s.classes,
11947            variables: s.variables,
11948            imports: s.imports,
11949        },
11950        languages,
11951        coverage,
11952    })
11953    .into_response()
11954}
11955
11956// ── Project history API ───────────────────────────────────────────────────────
11957// Protected. Called by the wizard JS when the project path changes, so the UI
11958// can show a "scanned N times before" badge without a full page reload.
11959//
11960// GET /api/project-history?path=<project_root>
11961
11962#[derive(Deserialize)]
11963struct ProjectHistoryQuery {
11964    path: Option<String>,
11965}
11966
11967#[derive(Serialize)]
11968struct ProjectHistoryResponse {
11969    scan_count: usize,
11970    last_scan_id: Option<String>,
11971    last_scan_timestamp: Option<String>,
11972    last_scan_code_lines: Option<u64>,
11973    last_git_branch: Option<String>,
11974    last_git_commit: Option<String>,
11975}
11976
11977/// Return true if `entry` matches either an exact root path or an upload-staging
11978/// path with the same project name (needed because each upload gets a fresh UUID dir).
11979fn entry_matches_project(
11980    entry: &RegistryEntry,
11981    root_str: &str,
11982    upload_root: &str,
11983    upload_name_suffix: Option<&str>,
11984) -> bool {
11985    if entry.input_roots.iter().any(|r| r == root_str) {
11986        return true;
11987    }
11988    if let Some(suffix) = upload_name_suffix {
11989        return entry
11990            .input_roots
11991            .iter()
11992            .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
11993    }
11994    false
11995}
11996
11997async fn project_history_handler(
11998    State(state): State<AppState>,
11999    Query(query): Query<ProjectHistoryQuery>,
12000) -> Response {
12001    let path = query.path.unwrap_or_default();
12002    let resolved = resolve_input_path(&path);
12003    let root_str = resolved.to_string_lossy().replace('\\', "/");
12004
12005    // In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
12006    // The UUID is freshly generated for every upload, so an exact root_str match never finds
12007    // previous scans of the same project. Fall back to matching by project name within the
12008    // uploads staging directory so Scan History populates correctly across uploads.
12009    let upload_root = std::env::temp_dir()
12010        .join("oxide-sloc-uploads")
12011        .to_string_lossy()
12012        .replace('\\', "/");
12013    let upload_name_suffix: Option<String> =
12014        if state.server_mode && root_str.starts_with(&upload_root) {
12015            resolved
12016                .file_name()
12017                .and_then(|n| n.to_str())
12018                .map(|name| format!("/{name}"))
12019        } else {
12020            None
12021        };
12022    let suffix_ref = upload_name_suffix.as_deref();
12023
12024    let entries: Vec<_> = {
12025        let reg = state.registry.lock().await;
12026        reg.entries
12027            .iter()
12028            .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
12029            .cloned()
12030            .collect()
12031    };
12032    let scan_count = entries.len();
12033    let last = entries.first();
12034    let last_scan_id = last.map(|e| e.run_id.clone());
12035    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
12036    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
12037    let last_git_branch = last.and_then(|e| e.git_branch.clone());
12038    let last_git_commit = last.and_then(|e| e.git_commit.clone());
12039
12040    Json(ProjectHistoryResponse {
12041        scan_count,
12042        last_scan_id,
12043        last_scan_timestamp,
12044        last_scan_code_lines,
12045        last_git_branch,
12046        last_git_commit,
12047    })
12048    .into_response()
12049}
12050
12051// ── Metrics history API ───────────────────────────────────────────────────────
12052// Protected. Returns a JSON array of lightweight scan snapshots for plotting
12053// trend charts.
12054//
12055// GET /api/metrics/history?root=<path>&limit=<n>
12056
12057#[derive(Deserialize)]
12058struct MetricsHistoryQuery {
12059    root: Option<String>,
12060    limit: Option<usize>,
12061    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
12062    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
12063    submodule: Option<String>,
12064}
12065
12066#[derive(Serialize)]
12067struct MetricsSubmoduleLink {
12068    name: String,
12069    url: String,
12070}
12071
12072#[derive(Serialize)]
12073struct MetricsHistoryEntry {
12074    run_id: String,
12075    run_id_short: String,
12076    timestamp: String,
12077    commit: Option<String>,
12078    branch: Option<String>,
12079    tags: Vec<String>,
12080    nearest_tag: Option<String>,
12081    code_lines: u64,
12082    comment_lines: u64,
12083    blank_lines: u64,
12084    physical_lines: u64,
12085    files_analyzed: u64,
12086    files_skipped: u64,
12087    test_count: u64,
12088    project_label: String,
12089    html_url: Option<String>,
12090    has_pdf: bool,
12091    submodule_links: Vec<MetricsSubmoduleLink>,
12092    /// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
12093    #[serde(skip_serializing_if = "Option::is_none")]
12094    coverage_line_pct: Option<f64>,
12095}
12096
12097fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
12098    let mut links: Vec<MetricsSubmoduleLink> = vec![];
12099    let sub_dir = e
12100        .html_path
12101        .as_ref()
12102        .and_then(|p| p.parent())
12103        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
12104    let Some(dir) = sub_dir else { return links };
12105    let Ok(rd) = std::fs::read_dir(dir) else {
12106        return links;
12107    };
12108    for entry_res in rd.flatten() {
12109        let fname = entry_res.file_name();
12110        let fname_str = fname.to_string_lossy();
12111        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
12112            let stem = &fname_str[..fname_str.len() - 5];
12113            let display = stem[4..].replace('-', " ");
12114            links.push(MetricsSubmoduleLink {
12115                name: display,
12116                url: format!("/runs/{stem}/{}", e.run_id),
12117            });
12118        }
12119    }
12120    links.sort_by(|a, b| a.name.cmp(&b.name));
12121    links
12122}
12123
12124fn apply_submodule_filter(
12125    base: MetricsHistoryEntry,
12126    filter: &str,
12127    e: &sloc_core::history::RegistryEntry,
12128) -> Option<MetricsHistoryEntry> {
12129    let json_path = e.json_path.as_ref()?;
12130    let json_str = std::fs::read_to_string(json_path).ok()?;
12131    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
12132    let sub = run
12133        .submodule_summaries
12134        .iter()
12135        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
12136    let safe = sanitize_project_label(&sub.name);
12137    let artifact_key = format!("sub_{safe}");
12138    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
12139        || base.html_url.clone(),
12140        |run_dir| {
12141            let sub_path = run_dir.join(format!("{artifact_key}.html"));
12142            if sub_path.exists() {
12143                Some(format!("/runs/{artifact_key}/{}", e.run_id))
12144            } else {
12145                base.html_url.clone()
12146            }
12147        },
12148    );
12149
12150    // Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
12151    // basic SLOC totals, so test_count and coverage must be computed from file records.
12152    let sub_files: Vec<_> = run
12153        .per_file_records
12154        .iter()
12155        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
12156        .collect();
12157    let test_count: u64 = sub_files
12158        .iter()
12159        .map(|r| r.raw_line_categories.test_count)
12160        .sum();
12161    #[allow(clippy::cast_precision_loss)]
12162    let coverage_line_pct: Option<f64> = {
12163        let found: u64 = sub_files
12164            .iter()
12165            .filter_map(|r| r.coverage.as_ref())
12166            .map(|c| u64::from(c.lines_found))
12167            .sum();
12168        let hit: u64 = sub_files
12169            .iter()
12170            .filter_map(|r| r.coverage.as_ref())
12171            .map(|c| u64::from(c.lines_hit))
12172            .sum();
12173        if found > 0 {
12174            let pct = (hit as f64 / found as f64) * 100.0;
12175            Some((pct * 10.0).round() / 10.0)
12176        } else {
12177            None
12178        }
12179    };
12180
12181    Some(MetricsHistoryEntry {
12182        code_lines: sub.code_lines,
12183        comment_lines: sub.comment_lines,
12184        blank_lines: sub.blank_lines,
12185        physical_lines: sub.total_physical_lines,
12186        files_analyzed: sub.files_analyzed,
12187        files_skipped: 0,
12188        test_count,
12189        html_url: sub_html_url,
12190        has_pdf: false,
12191        submodule_links: vec![],
12192        coverage_line_pct,
12193        ..base
12194    })
12195}
12196
12197#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
12198async fn api_metrics_history_handler(
12199    State(state): State<AppState>,
12200    Query(query): Query<MetricsHistoryQuery>,
12201) -> Response {
12202    let limit = query.limit.unwrap_or(50).min(500);
12203    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
12204
12205    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
12206        let reg = state.registry.lock().await;
12207        reg.entries
12208            .iter()
12209            .filter(|e| {
12210                query.root.as_ref().is_none_or(|root| {
12211                    let resolved = resolve_input_path(root);
12212                    let root_str = resolved.to_string_lossy().replace('\\', "/");
12213                    e.input_roots.iter().any(|r| r == &root_str)
12214                })
12215            })
12216            .take(limit)
12217            .cloned()
12218            .collect()
12219    };
12220
12221    let entries: Vec<MetricsHistoryEntry> = candidate_entries
12222        .into_iter()
12223        .filter_map(|e| {
12224            let tags = e
12225                .git_tags
12226                .as_deref()
12227                .map(|s| {
12228                    s.split(',')
12229                        .map(|t| t.trim().to_string())
12230                        .filter(|t| !t.is_empty())
12231                        .collect()
12232                })
12233                .unwrap_or_default();
12234            let html_url = e
12235                .html_path
12236                .as_ref()
12237                .filter(|p| p.exists())
12238                .map(|_| format!("/runs/html/{}", e.run_id));
12239            let nearest_tag = e.git_nearest_tag.clone();
12240            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
12241            let run_id_short: String = e
12242                .run_id
12243                .split('-')
12244                .next_back()
12245                .unwrap_or(&e.run_id)
12246                .chars()
12247                .take(7)
12248                .collect();
12249            let submodule_links = build_entry_submodule_links(&e);
12250            #[allow(clippy::cast_precision_loss)]
12251            let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
12252                let pct = (e.summary.coverage_lines_hit as f64
12253                    / e.summary.coverage_lines_found as f64)
12254                    * 100.0;
12255                Some((pct * 10.0).round() / 10.0)
12256            } else {
12257                None
12258            };
12259            let base = MetricsHistoryEntry {
12260                run_id: e.run_id.clone(),
12261                run_id_short,
12262                timestamp: e.timestamp_utc.to_rfc3339(),
12263                commit: e.git_commit.clone(),
12264                branch: e.git_branch.clone(),
12265                tags,
12266                nearest_tag,
12267                code_lines: e.summary.code_lines,
12268                comment_lines: e.summary.comment_lines,
12269                blank_lines: e.summary.blank_lines,
12270                physical_lines: e.summary.total_physical_lines,
12271                files_analyzed: e.summary.files_analyzed,
12272                files_skipped: e.summary.files_skipped,
12273                test_count: e.summary.test_count,
12274                project_label: e.project_label.clone(),
12275                html_url,
12276                has_pdf,
12277                submodule_links,
12278                coverage_line_pct,
12279            };
12280            if let Some(ref filter) = submodule_filter {
12281                apply_submodule_filter(base, filter, &e)
12282            } else {
12283                Some(base)
12284            }
12285        })
12286        .collect();
12287
12288    Json(entries).into_response()
12289}
12290
12291/// One scan's code churn versus the previous scan of the same project.
12292#[derive(Serialize)]
12293struct ChurnEntry {
12294    run_id: String,
12295    added: i64,
12296    removed: i64,
12297    modified: i64,
12298    unmodified: i64,
12299}
12300
12301// GET /api/metrics/churn?root=<path>&limit=<n>
12302// Returns per-scan SLOC churn (added/removed/modified/unmodified code lines) computed by
12303// comparing each scan to the previous scan of the same project. Loads per-file JSON
12304// artifacts, so it is intended for export-time use rather than every page load.
12305async fn api_metrics_churn_handler(
12306    State(state): State<AppState>,
12307    Query(query): Query<MetricsHistoryQuery>,
12308) -> Response {
12309    let limit = query.limit.unwrap_or(200).min(500);
12310    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
12311        let reg = state.registry.lock().await;
12312        reg.entries
12313            .iter()
12314            .filter(|e| {
12315                query.root.as_ref().is_none_or(|root| {
12316                    let resolved = resolve_input_path(root);
12317                    let root_str = resolved.to_string_lossy().replace('\\', "/");
12318                    e.input_roots.iter().any(|r| r == &root_str)
12319                })
12320            })
12321            .take(limit)
12322            .cloned()
12323            .collect()
12324    };
12325    let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
12326        std::collections::HashMap::new();
12327    for e in candidate_entries {
12328        by_project
12329            .entry(e.project_label.clone())
12330            .or_default()
12331            .push(e);
12332    }
12333    let mut out: Vec<ChurnEntry> = Vec::new();
12334    for (_proj, mut entries) in by_project {
12335        entries.sort_by_key(|e| e.timestamp_utc);
12336        let mut prev_run: Option<sloc_core::AnalysisRun> = None;
12337        for e in &entries {
12338            let curr = e
12339                .json_path
12340                .as_ref()
12341                .and_then(|path| sloc_core::read_json(path).ok());
12342            if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
12343                let cmp = sloc_core::compute_delta(prev, cur);
12344                out.push(ChurnEntry {
12345                    run_id: e.run_id.clone(),
12346                    added: sum_added_code_lines(&cmp),
12347                    removed: sum_removed_code_lines(&cmp),
12348                    modified: sum_modified_code_lines(&cmp),
12349                    unmodified: sum_unmodified_code_lines(&cmp),
12350                });
12351            } else {
12352                out.push(ChurnEntry {
12353                    run_id: e.run_id.clone(),
12354                    added: 0,
12355                    removed: 0,
12356                    modified: 0,
12357                    unmodified: 0,
12358                });
12359            }
12360            if curr.is_some() {
12361                prev_run = curr;
12362            }
12363        }
12364    }
12365    Json(out).into_response()
12366}
12367
12368// GET /api/metrics/submodules?root=<path>
12369// Returns the union of distinct submodule names found across all saved scan JSON artifacts
12370// for the given project root (or all roots if omitted).
12371#[derive(Deserialize)]
12372struct MetricsSubmodulesQuery {
12373    root: Option<String>,
12374}
12375
12376#[derive(Serialize)]
12377struct SubmoduleEntry {
12378    name: String,
12379    relative_path: String,
12380}
12381
12382async fn api_metrics_submodules_handler(
12383    State(state): State<AppState>,
12384    Query(query): Query<MetricsSubmodulesQuery>,
12385) -> Response {
12386    let json_paths: Vec<std::path::PathBuf> = {
12387        let reg = state.registry.lock().await;
12388        reg.entries
12389            .iter()
12390            .filter(|e| {
12391                query.root.as_ref().is_none_or(|root| {
12392                    let resolved = resolve_input_path(root);
12393                    let root_str = resolved.to_string_lossy().replace('\\', "/");
12394                    e.input_roots.iter().any(|r| r == &root_str)
12395                })
12396            })
12397            .filter_map(|e| e.json_path.clone())
12398            .collect()
12399    };
12400
12401    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
12402    let mut result: Vec<SubmoduleEntry> = Vec::new();
12403
12404    for path in &json_paths {
12405        let Ok(json_str) = tokio::fs::read_to_string(path).await else {
12406            continue;
12407        };
12408        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
12409            continue;
12410        };
12411        for sub in &run.submodule_summaries {
12412            if seen.insert(sub.name.clone()) {
12413                result.push(SubmoduleEntry {
12414                    name: sub.name.clone(),
12415                    relative_path: sub.relative_path.clone(),
12416                });
12417            }
12418        }
12419    }
12420
12421    result.sort_by(|a, b| a.name.cmp(&b.name));
12422    Json(result).into_response()
12423}
12424
12425// ── CI ingest endpoint ────────────────────────────────────────────────────────
12426// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
12427// server stores and displays results without cloning or scanning anything itself.
12428//
12429// POST /api/ingest?label=<optional_display_name>
12430// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
12431// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
12432
12433#[derive(Deserialize)]
12434struct IngestQuery {
12435    label: Option<String>,
12436}
12437
12438#[derive(Serialize)]
12439struct IngestResponse {
12440    run_id: String,
12441    view_url: String,
12442}
12443
12444async fn api_ingest_handler(
12445    State(state): State<AppState>,
12446    Query(q): Query<IngestQuery>,
12447    Json(run): Json<sloc_core::AnalysisRun>,
12448) -> Response {
12449    let label = q.label.unwrap_or_else(|| {
12450        run.input_roots
12451            .first()
12452            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
12453    });
12454
12455    let label_for_task = label.clone();
12456    let result = tokio::task::spawn_blocking(move || {
12457        let html = render_html(&run)?;
12458        let run_id = run.tool.run_id.clone();
12459        let run_id_safe = run_id.len() <= 128
12460            && !run_id.is_empty()
12461            && run_id
12462                .chars()
12463                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
12464        if !run_id_safe {
12465            anyhow::bail!(
12466                "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
12467            );
12468        }
12469        let project_label = sanitize_project_label(&label_for_task);
12470        let output_dir = resolve_output_root(None).join(derive_run_dir_name(
12471            &project_label,
12472            run.git_branch.as_deref(),
12473            &run_id,
12474        ));
12475        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
12476            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
12477            _ => project_label,
12478        };
12479        let (artifacts, _pending_pdf) = persist_run_artifacts(
12480            &run,
12481            &html,
12482            &output_dir,
12483            &label_for_task,
12484            &file_stem,
12485            RunResultContext::default(),
12486        )?;
12487        Ok::<_, anyhow::Error>((run_id, artifacts, run))
12488    })
12489    .await;
12490
12491    match result {
12492        Ok(Ok((run_id, artifacts, run))) => {
12493            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
12494            (
12495                StatusCode::CREATED,
12496                Json(IngestResponse {
12497                    view_url: format!("/view-reports?run_id={run_id}"),
12498                    run_id,
12499                }),
12500            )
12501                .into_response()
12502        }
12503        Ok(Err(e)) => error::internal(&format!("{e:#}")),
12504        Err(e) => error::internal(&format!("{e}")),
12505    }
12506}
12507
12508// ── Multi-compare page ────────────────────────────────────────────────────────
12509// GET /multi-compare?runs=id1,id2,id3,...
12510
12511fn html_escape(s: &str) -> String {
12512    s.replace('&', "&amp;")
12513        .replace('<', "&lt;")
12514        .replace('>', "&gt;")
12515        .replace('"', "&quot;")
12516}
12517
12518#[allow(clippy::cast_precision_loss)]
12519fn fmt_num(n: i64) -> String {
12520    let a = n.unsigned_abs();
12521    if a >= 1_000_000 {
12522        let v = n as f64 / 1_000_000.0;
12523        let s = format!("{v:.1}");
12524        format!("{}M", s.trim_end_matches(".0"))
12525    } else if a >= 10_000 {
12526        let v = n as f64 / 1_000.0;
12527        let s = format!("{v:.1}");
12528        format!("{}K", s.trim_end_matches(".0"))
12529    } else {
12530        let sign = if n < 0 { "-" } else { "" };
12531        if a < 1_000 {
12532            return format!("{sign}{a}");
12533        }
12534        format!("{sign}{},{:03}", a / 1_000, a % 1_000)
12535    }
12536}
12537
12538fn fmt_comma(n: i64) -> String {
12539    let sign = if n < 0 { "-" } else { "" };
12540    let a = n.unsigned_abs();
12541    if a < 1_000 {
12542        return format!("{sign}{a}");
12543    }
12544    let s = a.to_string();
12545    let bytes = s.as_bytes();
12546    let len = bytes.len();
12547    let mut out = String::with_capacity(len + len / 3);
12548    for (i, &b) in bytes.iter().enumerate() {
12549        if i > 0 && (len - i).is_multiple_of(3) {
12550            out.push(',');
12551        }
12552        out.push(b as char);
12553    }
12554    format!("{sign}{out}")
12555}
12556
12557/// Insert thousands separators into the integer portion of a number's textual form.
12558///
12559/// Works for plain integers (`"266148"` → `"266,148"`), signed values
12560/// (`"+1234"` → `"+1,234"`), and pre-formatted decimal strings
12561/// (`"16608.28"` → `"16,608.28"`). Any input whose integer part is not all
12562/// ASCII digits (e.g. `"—"`, `"No prior scan"`) is returned unchanged.
12563fn group_thousands(s: &str) -> String {
12564    let (sign, rest) = match s.as_bytes().first() {
12565        Some(b'-') => ("-", &s[1..]),
12566        Some(b'+') => ("+", &s[1..]),
12567        _ => ("", s),
12568    };
12569    let (int_part, frac_part) = match rest.split_once('.') {
12570        Some((i, f)) => (i, Some(f)),
12571        None => (rest, None),
12572    };
12573    if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
12574        return s.to_string();
12575    }
12576    let bytes = int_part.as_bytes();
12577    let len = bytes.len();
12578    let mut grouped = String::with_capacity(len + len / 3);
12579    for (i, &b) in bytes.iter().enumerate() {
12580        if i > 0 && (len - i).is_multiple_of(3) {
12581            grouped.push(',');
12582        }
12583        grouped.push(b as char);
12584    }
12585    frac_part.map_or_else(
12586        || format!("{sign}{grouped}"),
12587        |f| format!("{sign}{grouped}.{f}"),
12588    )
12589}
12590
12591/// Custom Askama filters available to templates in this crate.
12592mod filters {
12593    // These lints fire on the wrapper code generated by `#[askama::filter_fn]`
12594    // (a `&self` `execute` method returning `Result`), not on our own source.
12595    #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
12596    use askama::{Result, Values};
12597
12598    /// `{{ value|commas }}` — render any `Display` value with thousands separators.
12599    ///
12600    /// Integers and pre-formatted decimal strings are grouped; non-numeric text
12601    /// (dashes, "No prior scan", etc.) passes through untouched.
12602    #[askama::filter_fn]
12603    pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
12604        Ok(super::group_thousands(&value.to_string()))
12605    }
12606}
12607
12608#[derive(Deserialize, Default)]
12609struct MultiCompareQuery {
12610    runs: Option<String>,
12611    /// "super" to show only super-repo files (exclude all submodule files)
12612    scope: Option<String>,
12613    /// Submodule name to narrow the comparison to one submodule
12614    sub: Option<String>,
12615}
12616
12617#[allow(clippy::too_many_lines)]
12618async fn multi_compare_handler(
12619    State(state): State<AppState>,
12620    Query(params): Query<MultiCompareQuery>,
12621    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
12622) -> impl IntoResponse {
12623    let run_ids: Vec<String> = params
12624        .runs
12625        .as_deref()
12626        .unwrap_or("")
12627        .split(',')
12628        .map(|s| s.trim().to_string())
12629        .filter(|s| !s.is_empty())
12630        .collect();
12631
12632    if run_ids.len() < 2 {
12633        return Html(
12634            "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
12635             <a href=\"/compare-scans\">Go back</a></p>",
12636        )
12637        .into_response();
12638    }
12639    if run_ids.len() > 20 {
12640        return Html(
12641            "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
12642             at once. <a href=\"/compare-scans\">Go back</a></p>",
12643        )
12644        .into_response();
12645    }
12646
12647    // Look up each run_id in the registry.
12648    let entries: Vec<Option<RegistryEntry>> = {
12649        let reg = state.registry.lock().await;
12650        run_ids
12651            .iter()
12652            .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
12653            .collect()
12654    };
12655
12656    for (i, entry) in entries.iter().enumerate() {
12657        if entry.is_none() {
12658            let html = format!(
12659                "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
12660                 found. <a href=\"/compare-scans\">Go back</a></p>",
12661                run_ids[i]
12662            );
12663            return Html(html).into_response();
12664        }
12665    }
12666
12667    let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
12668
12669    for entry in &entries {
12670        if entry.json_path.is_none() {
12671            let html = format!(
12672                "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
12673                 JSON data — re-run the analysis to enable comparison. \
12674                 <a href=\"/compare-scans\">Go back</a></p>",
12675                entry.run_id
12676            );
12677            return Html(html).into_response();
12678        }
12679    }
12680
12681    // Sort chronologically.
12682    entries.sort_by_key(|e| e.timestamp_utc);
12683
12684    // Load JSON for each entry.
12685    let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
12686    for entry in &entries {
12687        let path = entry.json_path.as_ref().unwrap();
12688        match read_json(path) {
12689            Ok(r) => runs.push(r),
12690            Err(e) => {
12691                let html = format!(
12692                    "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
12693                     <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
12694                    entry.run_id
12695                );
12696                return Html(html).into_response();
12697            }
12698        }
12699    }
12700
12701    // Collect submodule names from all runs.
12702    let all_sub_names: Vec<String> = {
12703        let mut set = std::collections::BTreeSet::new();
12704        for r in &runs {
12705            for s in &r.submodule_summaries {
12706                set.insert(s.name.clone());
12707            }
12708        }
12709        set.into_iter().collect()
12710    };
12711    let has_submodule_data = !all_sub_names.is_empty();
12712    let active_submodule = params.sub.clone();
12713    let super_scope_active = params.scope.as_deref() == Some("super");
12714
12715    // Narrow per_file_records when a scope is active, then recompute totals.
12716    apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
12717
12718    let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
12719    let project_label = entries
12720        .first()
12721        .map_or("", |e| e.project_label.as_str())
12722        .to_string();
12723    let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
12724    let multi = compute_multi_delta(&run_refs);
12725    let html = multi_compare_page(
12726        &multi,
12727        &project_label,
12728        env!("CARGO_PKG_VERSION"),
12729        &csp_nonce,
12730        has_submodule_data,
12731        &all_sub_names,
12732        &runs_csv,
12733        super_scope_active,
12734        active_submodule.as_deref(),
12735        &entries,
12736    );
12737    // no-store: this page is regenerated on every request and embeds inline JS; a cached
12738    // copy after a rebuild would silently mask UI fixes.
12739    (
12740        [(axum::http::header::CACHE_CONTROL, "no-store")],
12741        Html(html),
12742    )
12743        .into_response()
12744}
12745
12746const fn multi_delta_class(n: i64) -> &'static str {
12747    match n {
12748        1.. => "pos",
12749        ..=-1 => "neg",
12750        0 => "zero",
12751    }
12752}
12753
12754fn multi_fmt_delta(n: i64) -> String {
12755    if n > 0 {
12756        format!("+{n}")
12757    } else {
12758        format!("{n}")
12759    }
12760}
12761
12762/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
12763fn js_escape(s: &str) -> String {
12764    use std::fmt::Write as _;
12765    let mut out = String::with_capacity(s.len() + 2);
12766    for c in s.chars() {
12767        match c {
12768            '"' => out.push_str("\\\""),
12769            '\\' => out.push_str("\\\\"),
12770            '\n' => out.push_str("\\n"),
12771            '\r' => out.push_str("\\r"),
12772            '\t' => out.push_str("\\t"),
12773            c if (c as u32) < 0x20 => {
12774                let _ = write!(out, "\\u{:04x}", c as u32);
12775            }
12776            c => out.push(c),
12777        }
12778    }
12779    out
12780}
12781
12782/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
12783fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
12784    let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
12785        return (
12786            "&mdash;".to_string(),
12787            "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
12788        );
12789    };
12790    let cd = entry
12791        .git_commit_date
12792        .as_deref()
12793        .and_then(fmt_git_date)
12794        .unwrap_or_else(|| "&mdash;".to_string());
12795    let au = entry.git_author.as_deref().map_or_else(
12796        || "<span class=\"mc-row-val\">&mdash;</span>".to_string(),
12797        |a| {
12798            format!(
12799                "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
12800                 <span class=\"cmp-author-handle\"></span></span>",
12801                html_escape(a)
12802            )
12803        },
12804    );
12805    (cd, au)
12806}
12807
12808/// Render the scope badge chip for a scan card header.
12809fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
12810    active_sub.map_or_else(
12811        || {
12812            if super_scope_active {
12813                "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
12814            } else {
12815                "<span class=\"mc-scope-tag mc-scope-full\">\
12816                 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
12817                 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
12818                 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
12819                 <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>\
12820                 </svg> Full scan</span>"
12821                    .to_string()
12822            }
12823        },
12824        |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
12825    )
12826}
12827
12828/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
12829fn build_mc_scan_strip(
12830    multi: &MultiScanComparison,
12831    entries: &[RegistryEntry],
12832    n: usize,
12833    is_many: bool,
12834    active_sub: Option<&str>,
12835    super_scope_active: bool,
12836    project_label: &str,
12837) -> String {
12838    use std::fmt::Write as _;
12839    let mut scan_strip = String::new();
12840    for (i, pt) in multi.points.iter().enumerate() {
12841        let ts_ms = pt.timestamp.timestamp_millis();
12842        let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
12843        let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
12844        let branch = pt.git_branch.as_deref().unwrap_or("");
12845        let report_link = format!("/runs/html/{}", pt.run_id);
12846        let branch_html = if branch.is_empty() {
12847            "<span class=\"mc-row-val\">&mdash;</span>".to_string()
12848        } else {
12849            format!(
12850                "<span class=\"mc-card-branch\">{}</span>",
12851                html_escape(branch)
12852            )
12853        };
12854        let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
12855        let tags_html = pt
12856            .git_tags
12857            .as_deref()
12858            .filter(|t| !t.is_empty())
12859            .map(|t| {
12860                let chips = t
12861                    .split(',')
12862                    .filter(|s| !s.is_empty())
12863                    .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
12864                    .collect::<Vec<_>>()
12865                    .join(" ");
12866                format!(
12867                    "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
12868                     <span class=\"mc-row-val\">{chips}</span></div>"
12869                )
12870            })
12871            .unwrap_or_default();
12872        let nearest = pt
12873            .git_nearest_tag
12874            .as_deref()
12875            .map(|t| format!("near {}", html_escape(t)))
12876            .unwrap_or_default();
12877        let arrow = if i < n - 1 && !is_many {
12878            "<div class='mc-arrow'>&#8594;</div>"
12879        } else {
12880            ""
12881        };
12882        let scope_badge = mc_scope_badge(active_sub, super_scope_active);
12883        let nearest_html = if nearest.is_empty() {
12884            String::new()
12885        } else {
12886            format!(
12887                "<span class=\"mc-card-nearest-wrap\">\
12888                 <span class=\"mc-card-nearest\">{nearest}</span>\
12889                 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
12890                 </span>"
12891            )
12892        };
12893        write!(
12894            scan_strip,
12895            r#"<div class="mc-card">
12896              <div class="mc-card-header">
12897                <div class="mc-card-num">Scan {num}</div>
12898                <div class="mc-card-project-col">
12899                  <div class="mc-card-project">{project_label}</div>
12900                  {scope_badge}
12901                </div>
12902              </div>
12903              <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
12904              <div class="mc-card-rows">
12905                <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
12906                <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
12907                <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
12908                <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>
12909                {tags_html}
12910              </div>
12911              <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
12912            </div>{arrow}"#,
12913            num = i + 1,
12914            commit = html_escape(commit),
12915            commit_date = commit_date_html,
12916            ts_ms = ts_ms,
12917            code = fmt_num(pt.code_lines),
12918            scope_badge = scope_badge,
12919            nearest_html = nearest_html,
12920        )
12921        .unwrap();
12922    }
12923    scan_strip
12924}
12925
12926/// Build the metric progression table (thead + tbody) for multi-compare.
12927#[allow(clippy::too_many_lines)]
12928fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
12929    use std::fmt::Write as _;
12930    struct MetricRow<'a> {
12931        label: &'a str,
12932        values: Vec<i64>,
12933        seq_deltas: Vec<i64>,
12934        net_delta: i64,
12935    }
12936    let rows: Vec<MetricRow<'_>> = vec![
12937        MetricRow {
12938            label: "Code Lines",
12939            values: multi.points.iter().map(|p| p.code_lines).collect(),
12940            seq_deltas: multi
12941                .sequential_deltas
12942                .iter()
12943                .map(|d| d.summary.code_lines_delta)
12944                .collect(),
12945            net_delta: multi.total_delta.code_lines_delta,
12946        },
12947        MetricRow {
12948            label: "Files Analyzed",
12949            values: multi.points.iter().map(|p| p.files_analyzed).collect(),
12950            seq_deltas: multi
12951                .sequential_deltas
12952                .iter()
12953                .map(|d| d.summary.files_analyzed_delta)
12954                .collect(),
12955            net_delta: multi.total_delta.files_analyzed_delta,
12956        },
12957        MetricRow {
12958            label: "Comment Lines",
12959            values: multi.points.iter().map(|p| p.comment_lines).collect(),
12960            seq_deltas: multi
12961                .sequential_deltas
12962                .iter()
12963                .map(|d| d.summary.comment_lines_delta)
12964                .collect(),
12965            net_delta: multi.total_delta.comment_lines_delta,
12966        },
12967        MetricRow {
12968            label: "Blank Lines",
12969            values: multi.points.iter().map(|p| p.blank_lines).collect(),
12970            seq_deltas: multi
12971                .sequential_deltas
12972                .iter()
12973                .map(|d| d.summary.blank_lines_delta)
12974                .collect(),
12975            net_delta: multi.total_delta.blank_lines_delta,
12976        },
12977        MetricRow {
12978            label: "Tests",
12979            values: multi.points.iter().map(|p| p.test_count).collect(),
12980            seq_deltas: multi
12981                .points
12982                .windows(2)
12983                .map(|pts| pts[1].test_count - pts[0].test_count)
12984                .collect(),
12985            net_delta: multi.points.last().map_or(0, |l| l.test_count)
12986                - multi.points.first().map_or(0, |f| f.test_count),
12987        },
12988    ];
12989    let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
12990    for i in 0..n {
12991        write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
12992        if i < n - 1 {
12993            metrics_thead.push_str("<th class='mc-delta-col'>&#8594;&#916;</th>");
12994        }
12995    }
12996    metrics_thead.push_str("<th class='mc-net-col'>Net &#916;</th></tr>");
12997    let mut metrics_tbody = String::new();
12998    for row in &rows {
12999        metrics_tbody.push_str("<tr>");
13000        write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
13001        for i in 0..n {
13002            write!(
13003                metrics_tbody,
13004                "<td class='mc-val-col'>{}</td>",
13005                fmt_comma(row.values[i])
13006            )
13007            .unwrap();
13008            if i < n - 1 {
13009                let d = row.seq_deltas[i];
13010                write!(
13011                    metrics_tbody,
13012                    "<td class='mc-delta-col {cls}'>{val}</td>",
13013                    cls = multi_delta_class(d),
13014                    val = multi_fmt_delta(d)
13015                )
13016                .unwrap();
13017            }
13018        }
13019        let nd = row.net_delta;
13020        write!(
13021            metrics_tbody,
13022            "<td class='mc-net-col {cls}'>{val}</td>",
13023            cls = multi_delta_class(nd),
13024            val = multi_fmt_delta(nd)
13025        )
13026        .unwrap();
13027        metrics_tbody.push_str("</tr>");
13028    }
13029    (metrics_thead, metrics_tbody)
13030}
13031
13032/// Build the JS-embeddable points JSON array for the multi-compare chart.
13033fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
13034    let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
13035    for (i, pt) in multi.points.iter().enumerate() {
13036        let commit = pt.git_commit.as_deref().unwrap_or("");
13037        let branch = pt.git_branch.as_deref().unwrap_or("");
13038        let tags = pt.git_tags.as_deref().unwrap_or("");
13039        let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
13040        let scanned_ms = pt.timestamp.timestamp_millis();
13041        let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
13042        let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
13043        let commit_date = entry
13044            .and_then(|e| e.git_commit_date.as_deref())
13045            .and_then(fmt_git_date)
13046            .unwrap_or_default();
13047        let author = entry
13048            .and_then(|e| e.git_author.as_deref())
13049            .unwrap_or("")
13050            .to_string();
13051        let cov = pt
13052            .coverage_line_pct
13053            .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
13054        parts.push(format!(
13055            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}}}"#,
13056            run_id = js_escape(&pt.run_id),
13057            commit = js_escape(commit),
13058            branch = js_escape(branch),
13059            tags = js_escape(tags),
13060            nearest = js_escape(nearest),
13061            commit_date = js_escape(&commit_date),
13062            author = js_escape(&author),
13063            scanned = js_escape(&scanned),
13064            code = pt.code_lines,
13065            comments = pt.comment_lines,
13066            blank = pt.blank_lines,
13067            files = pt.files_analyzed,
13068            tests = pt.test_count,
13069        ));
13070    }
13071    format!("[{}]", parts.join(","))
13072}
13073
13074/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
13075fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
13076    let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
13077    for row in &multi.file_matrix {
13078        let lang = row.language.as_deref().unwrap_or("");
13079        let codes: Vec<String> = row
13080            .code_per_scan
13081            .iter()
13082            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
13083            .collect();
13084        let deltas: Vec<String> = row
13085            .code_delta_per_scan
13086            .iter()
13087            .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
13088            .collect();
13089        parts.push(format!(
13090            r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
13091            path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
13092            status = row.overall_status,
13093            codes = codes.join(","),
13094            deltas = deltas.join(","),
13095            total = row.total_code_delta,
13096        ));
13097    }
13098    format!("[{}]", parts.join(","))
13099}
13100
13101/// Build the column header cells for the file-matrix table.
13102fn build_mc_file_col_headers(n: usize) -> String {
13103    use std::fmt::Write as _;
13104    let mut out = String::new();
13105    for i in 0..n {
13106        write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
13107        if i < n - 1 {
13108            write!(
13109                out,
13110                "<th class='file-delta-col'>&#916;&#8594;{}</th>",
13111                i + 2
13112            )
13113            .unwrap();
13114        }
13115    }
13116    out
13117}
13118
13119/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
13120fn build_mc_scope_bar(
13121    has_submodule_data: bool,
13122    sub_names: &[String],
13123    runs_csv: &str,
13124    active_sub: Option<&str>,
13125    super_scope_active: bool,
13126) -> String {
13127    use std::fmt::Write as _;
13128    if !has_submodule_data {
13129        return String::new();
13130    }
13131    let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
13132    let full_active = active_sub.is_none() && !super_scope_active;
13133    let mut bar = format!(
13134        r#"<div class="submod-scope-bar">
13135  <span class="submod-scope-label">
13136    <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>
13137    Scope:
13138  </span>
13139  <div class="submod-scope-divider"></div>
13140  <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
13141  <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>"#,
13142        full_cls = if full_active { " active" } else { "" },
13143        super_cls = if super_scope_active { " active" } else { "" },
13144    );
13145    for s in sub_names {
13146        let is_active = active_sub == Some(s.as_str());
13147        write!(
13148            bar,
13149            "\n  <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&amp;sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
13150            cls = if is_active { " active" } else { "" },
13151            name_enc = html_escape(s),
13152            name_esc = html_escape(s),
13153        )
13154        .unwrap();
13155    }
13156    bar.push_str("\n</div>");
13157    bar
13158}
13159
13160/// Build the scope-description label shown in the page subtitle.
13161fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
13162    active_sub.map_or_else(
13163        || {
13164            if super_scope_active {
13165                "Super-repo only &mdash; ".to_string()
13166            } else {
13167                String::new()
13168            }
13169        },
13170        |s| format!("Submodule: {} &mdash; ", html_escape(s)),
13171    )
13172}
13173
13174#[allow(clippy::too_many_lines)]
13175#[allow(clippy::too_many_arguments)]
13176fn multi_compare_page(
13177    multi: &MultiScanComparison,
13178    project_label: &str,
13179    version: &str,
13180    csp_nonce: &str,
13181    has_submodule_data: bool,
13182    sub_names: &[String],
13183    runs_csv: &str,
13184    super_scope_active: bool,
13185    active_sub: Option<&str>,
13186    entries: &[RegistryEntry],
13187) -> String {
13188    let n = multi.points.len();
13189    let is_many = n > 4;
13190    let mc_strip_class = if is_many {
13191        "mc-strip mc-strip-grid"
13192    } else {
13193        "mc-strip"
13194    };
13195
13196    // ── Scan strip cards ──────────────────────────────────────────────────────
13197    let scan_strip = build_mc_scan_strip(
13198        multi,
13199        entries,
13200        n,
13201        is_many,
13202        active_sub,
13203        super_scope_active,
13204        project_label,
13205    );
13206
13207    // ── Summary metrics table ─────────────────────────────────────────────────
13208    let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
13209
13210    // ── Chart data and table helpers ──────────────────────────────────────────
13211    let points_json = build_mc_points_json(multi, entries);
13212    let file_matrix_json = build_mc_file_matrix_json(multi);
13213
13214    // Counts for filter tabs
13215    let files_modified = multi
13216        .file_matrix
13217        .iter()
13218        .filter(|f| f.overall_status == "modified")
13219        .count();
13220    let files_added = multi
13221        .file_matrix
13222        .iter()
13223        .filter(|f| f.overall_status == "added")
13224        .count();
13225    let files_removed = multi
13226        .file_matrix
13227        .iter()
13228        .filter(|f| f.overall_status == "removed")
13229        .count();
13230    let files_unchanged = multi
13231        .file_matrix
13232        .iter()
13233        .filter(|f| f.overall_status == "unchanged")
13234        .count();
13235    let total_files = multi.file_matrix.len();
13236
13237    let file_col_headers = build_mc_file_col_headers(n);
13238    let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
13239    let scope_bar_html = build_mc_scope_bar(
13240        has_submodule_data,
13241        sub_names,
13242        runs_csv,
13243        active_sub,
13244        super_scope_active,
13245    );
13246    let scope_label = build_mc_scope_label(active_sub, super_scope_active);
13247    let toast_assets = sloc_toast_assets(csp_nonce);
13248
13249    format!(
13250        r#"<!doctype html>
13251<html lang="en">
13252<head>
13253  <meta charset="utf-8">
13254  <meta name="viewport" content="width=device-width, initial-scale=1">
13255  <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
13256  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13257  <link rel="stylesheet" href="/static/app.css">
13258  <script src="/static/app.js"></script>
13259  <style nonce="{csp_nonce}">
13260    :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;}}
13261    *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
13262    body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
13263    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;}}
13264    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13265    .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
13266    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13267    .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
13268    @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));}}}}
13269    .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);}}
13270    .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;}}
13271    @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
13272    @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
13273    @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;}}}}
13274    .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
13275    .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));}}
13276    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13277    .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
13278    .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
13279    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
13280    .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;}}
13281    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13282    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
13283    .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13284    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13285    .nav-dropdown{{position:relative;display:inline-flex;}}
13286    .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;}}
13287    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13288    .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;}}
13289    .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
13290    .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);}}
13291    .nav-dropdown-menu a:last-child{{border-bottom:none;}}
13292    .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
13293    .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
13294    body:not(.dark-theme) .icon-sun{{display:none;}}
13295    body.dark-theme .icon-moon{{display:none;}}
13296    .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;}}
13297    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13298    .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);}}
13299    .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;}}
13300    .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
13301    .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13302    .settings-modal-body{{padding:14px 16px 16px;}}
13303    .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13304    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13305    .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;}}
13306    .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
13307    .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13308    .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
13309    .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13310    .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;}}
13311    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13312    .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;}}
13313    .btn-back:hover{{background:var(--line);}}
13314    .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;}}
13315    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;}}
13316    .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
13317    .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
13318    .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
13319    .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;}}
13320    .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;}}
13321    .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
13322    .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;}}
13323    .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
13324    body.dark-theme .mc-card{{background:var(--surface-2);}}
13325    .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
13326    .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
13327    .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%;}}
13328    .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;}}
13329    .mc-card-commit:hover{{color:var(--oxide);}}
13330    .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
13331    .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
13332    .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;}}
13333    .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
13334    .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;}}
13335    .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;}}
13336    .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
13337    .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;}}
13338    .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
13339    .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
13340    .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
13341    .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
13342    .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
13343    .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);}}
13344    .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);}}
13345    .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
13346    .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;}}
13347    .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
13348    .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;}}
13349    .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
13350    .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;}}
13351    .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
13352    .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;}}
13353    .submod-scope-btn:hover{{background:var(--line);}}
13354    .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13355    .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
13356    .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;}}
13357    .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
13358    .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13359    .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
13360    .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
13361    .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
13362    .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
13363    .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
13364    .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
13365    .metrics-table .pos{{color:var(--pos);}}
13366    .metrics-table .neg{{color:var(--neg);}}
13367    .metrics-table .zero{{color:var(--muted);}}
13368    .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
13369    .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
13370    .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;}}
13371    .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13372    .chart-metric-btn:hover:not(.active){{background:var(--line);}}
13373    .chart-wrap{{width:100%;overflow-x:auto;}}
13374    #mc-chart{{display:block;width:100%;}}
13375    h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
13376    .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
13377    .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
13378    @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
13379    .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13380    body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
13381    .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
13382    .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
13383    .ic-card-h2-row .ic-card-h2{{margin:0;}}
13384    .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13385    .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;}}
13386    .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13387    .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;}}
13388    .ic-svg-modal-ov.open{{display:flex;}}
13389    .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);}}
13390    .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);}}
13391    .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
13392    .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;}}
13393    .ic-svg-modal-close:hover{{background:var(--line);}}
13394    .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
13395    .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
13396    .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
13397    .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
13398    .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
13399    .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
13400    #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;}}
13401    .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
13402    .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
13403    .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;}}
13404    .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13405    .tab-btn:hover:not(.active){{background:var(--line);}}
13406    .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
13407    .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
13408    .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
13409    .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
13410    .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
13411    .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
13412    body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
13413    body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
13414    body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
13415    .table-wrap{{width:100%;overflow-x:auto;}}
13416    #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
13417    #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
13418    #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;}}
13419    #file-table th.left,#file-table td.left{{text-align:left;}}
13420    .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
13421    .file-delta-col{{color:var(--muted);font-size:11px;}}
13422    .file-net-col{{font-weight:800;}}
13423    .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
13424    #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
13425    #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
13426    #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
13427    .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
13428    .status-badge.modified{{background:#fff2d8;color:#926000;}}
13429    .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
13430    .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
13431    .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
13432    body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
13433    body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
13434    body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
13435    tr.row-added td{{background:rgba(26,143,71,0.04);}}
13436    tr.row-removed td{{background:rgba(179,59,59,0.06);}}
13437    tr.row-modified td{{background:rgba(146,96,0,0.04);}}
13438    tr.row-unchanged td{{color:var(--muted);}}
13439    tr.row-unchanged .status-badge{{opacity:.65;}}
13440    .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;}}
13441    .absent{{color:var(--muted);font-style:italic;}}
13442    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
13443    .pagination-info{{font-size:12px;color:var(--muted);}}
13444    .pagination-btns{{display:flex;gap:5px;}}
13445    .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;}}
13446    .pg-btn:hover:not(:disabled){{background:var(--line);}}
13447    .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13448    .pg-btn:disabled{{opacity:.35;cursor:default;}}
13449    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;}}
13450    .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;}}
13451    .export-btn:hover{{background:var(--line);}}
13452    .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;}}
13453    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13454    .site-footer a{{color:var(--muted);}}
13455    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;}}
13456    body.pdf-mode{{background:#fff!important;}}
13457    body.pdf-mode .page{{padding:4px 6px 4px!important;}}
13458    .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;}}
13459    .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
13460    .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;}}
13461    .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;}}
13462    .mc-modal-title{{font-size:18px;font-weight:800;}}
13463    .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
13464    .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;}}
13465    .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
13466    .mc-modal-body{{padding:18px 22px;}}
13467    .mc-modal-sec{{margin-bottom:20px;}}
13468    .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
13469    .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
13470    .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;}}
13471    .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
13472    .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
13473    .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;}}
13474    .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
13475    .mc-modal-row:last-child{{border-bottom:none;}}
13476    .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
13477    .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
13478    .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
13479    .mc-modal-val a:hover{{text-decoration:underline;}}
13480    body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
13481    body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
13482    .mc-modal-stat[data-tip]{{cursor:help;}}
13483    #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);}}
13484    .mc-card{{cursor:pointer;}}
13485    .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
13486  </style>
13487</head>
13488<body>
13489  {loading_overlay}
13490  <div class="background-watermarks" aria-hidden="true">
13491    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13492    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13493    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13494    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13495    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13496    <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13497  </div>
13498  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13499  <div class="top-nav">
13500    <div class="top-nav-inner">
13501      <a class="brand" href="/">
13502        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13503        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
13504      </a>
13505      <div class="nav-right">
13506        <a class="nav-pill" href="/">Home</a>
13507        <div class="nav-dropdown">
13508          <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>
13509          <div class="nav-dropdown-menu">
13510            <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>
13511          </div>
13512        </div>
13513        <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
13514        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13515        <div class="nav-dropdown">
13516          <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>
13517          <div class="nav-dropdown-menu">
13518            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
13519            <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>
13520          </div>
13521        </div>
13522        <div class="server-status-wrap" id="server-status-wrap">
13523          <div class="nav-pill server-online-pill" id="server-status-pill">
13524            <span class="status-dot" id="status-dot"></span>
13525            <span id="server-status-label">Server</span>
13526            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
13527          </div>
13528          <div class="server-status-tip">
13529            OxideSLOC is running &mdash; accessible on your network.
13530            <span class="sx-238af6bc" id="server-tip-ping" ></span>
13531          </div>
13532        </div>
13533        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13534          <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>
13535        </button>
13536        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13537          <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>
13538          <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>
13539        </button>
13540      </div>
13541    </div>
13542  </div>
13543
13544  <div class="page">
13545    <!-- Hero header -->
13546    <div class="mc-hero">
13547      <div class="mc-hero-header">
13548        <div>
13549          <div class="mc-title">Multi-Scan Timeline</div>
13550          <p class="mc-desc">Side-by-side metric comparison across multiple scans &mdash; code line progression, file changes, and language breakdown.</p>
13551          <div class="mc-subtitle">{scope_label}{n} scans &middot; project: <strong>{project_label}</strong></div>
13552        </div>
13553        <div class="sx-9ca10e51" >
13554          <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>
13555          <div class="export-group" id="mc-top-export-group">
13556            <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>
13557            <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>
13558          </div>
13559        </div>
13560      </div>
13561      {scope_bar_html}
13562      <!-- Scan strip -->
13563      <div class="{mc_strip_class}">{scan_strip}</div>
13564    </div>
13565
13566    <!-- Summary metrics table -->
13567    <div class="panel">
13568      <div class="panel-title">Metric Progression</div>
13569      <div class="table-wrap">
13570        <table class="metrics-table">
13571          <thead>{metrics_thead}</thead>
13572          <tbody>{metrics_tbody}</tbody>
13573        </table>
13574      </div>
13575    </div>
13576
13577    <!-- Scan Charts -->
13578    <div class="panel" id="mc-charts-panel">
13579      <div class="panel-title sx-16dbf2a3" >Scan Delta Charts</div>
13580      <div class="ic-grid">
13581        <!-- Timeline line chart — spans full width -->
13582        <div class="ic-card sx-aeb7cdee" >
13583          <div class="ic-card-h2-row">
13584            <span class="ic-card-h2">Timeline</span>
13585            <div class="chart-toolbar sx-ab79ea2b" >
13586              <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
13587              <button class="chart-metric-btn" data-metric="files">Files</button>
13588              <button class="chart-metric-btn" data-metric="comments">Comments</button>
13589              <button class="chart-metric-btn" data-metric="tests">Tests</button>
13590              <button class="chart-metric-btn" data-metric="cov">Coverage</button>
13591            </div>
13592          </div>
13593          <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
13594        </div>
13595        <!-- Code Metrics: Scan 1 vs Latest -->
13596        <div class="ic-card">
13597          <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>
13598          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot sx-618fd811" ></span><span class="sx-d50d9131" >Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot sx-d94e9768" ></span><span class="sx-f6800712" >Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot sx-38f87134" ></span><span class="sx-c64494ae" >Comments</span></span><span class="sx-53b2a74a" >(faded&nbsp;=&nbsp;scan&nbsp;1)</span></div>
13599          <div id="mc-ic-c1"></div>
13600        </div>
13601        <!-- Language Code Delta -->
13602        <div class="ic-card" id="mc-ic-lang-card">
13603          <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>
13604          <div class="sx-a89fa233" >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>
13605          <div id="mc-ic-c3"></div>
13606        </div>
13607        <!-- Delta by Metric -->
13608        <div class="ic-card">
13609          <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>
13610          <div id="mc-ic-c2"></div>
13611        </div>
13612        <!-- File Change Distribution -->
13613        <div class="ic-card">
13614          <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>
13615          <div id="mc-ic-c4"></div>
13616        </div>
13617      </div>
13618    </div>
13619
13620    <!-- File matrix table -->
13621    <div class="panel">
13622      <div class="panel-title">File Matrix <span class="sx-8bdabd9b" >{total_files} files</span></div>
13623      <div class="sx-a86a62cc" >
13624        <div class="filter-tabs-row sx-ce1e99f1" >
13625          <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
13626          <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
13627          <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
13628          <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
13629          <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
13630        </div>
13631        <div class="sx-9ca10e51" >
13632          <span class="delta-note">* &#916; = delta (change from scan 1 &rarr; latest)</span>
13633          <div class="export-group">
13634          <button type="button" class="export-btn" id="mc-file-reset-btn">&#8635; Reset</button>
13635          <button type="button" class="export-btn" id="export-csv-btn">
13636            <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>
13637            CSV
13638          </button>
13639          <button type="button" class="export-btn" id="mc-file-xls-btn">
13640            <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>
13641            Excel
13642          </button>
13643          </div>
13644        </div>
13645      </div>
13646      <div class="table-wrap">
13647        <table id="file-table">
13648          <thead>
13649            <tr>
13650              <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">&#8597;</span></th>
13651              <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">&#8597;</span></th>
13652              <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">&#8597;</span></th>
13653              {file_col_headers}
13654              <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net &#916; <span class="sort-icon">&#8597;</span></th>
13655            </tr>
13656          </thead>
13657          <tbody id="file-tbody"></tbody>
13658        </table>
13659      </div>
13660      <div class="pagination">
13661        <span class="pagination-info" id="pg-info"></span>
13662        <div class="pagination-btns" id="pg-btns"></div>
13663        <div class="sx-047507c3" >
13664          <span class="sx-14f170bb" >Show</span>
13665          <select class="per-page" id="per-page-sel">
13666            <option value="25" selected>25 per page</option>
13667            <option value="50">50 per page</option>
13668            <option value="100">100 per page</option>
13669          </select>
13670        </div>
13671      </div>
13672    </div>
13673  </div>
13674
13675  <div id="mc-ic-tt"></div>
13676
13677  <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
13678    <div class="ic-svg-modal">
13679      <div class="ic-svg-modal-hdr">
13680        <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
13681        <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
13682      </div>
13683      <div id="ic-svg-modal-body"></div>
13684    </div>
13685  </div>
13686
13687  <footer class="site-footer">
13688    oxide-sloc v{version} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
13689    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13690    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13691    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13692    &nbsp;&middot;&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13693  </footer>
13694
13695  <script nonce="{csp_nonce}">
13696  (function(){{
13697    // ── Dark theme ───────────────────────────────────────────────────────────
13698    try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
13699    var renderInlineCharts=null;
13700    var tt=document.getElementById('theme-toggle');
13701    if(tt)tt.addEventListener('click',function(){{
13702      var on=document.body.classList.toggle('dark-theme');
13703      try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
13704      renderChart(activeMetric);
13705      if(renderInlineCharts)renderInlineCharts();
13706    }});
13707
13708    // ── Code particles ───────────────────────────────────────────────────────
13709    var container=document.getElementById('code-particles');
13710    if(container){{
13711      var snips = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
13712      for(var i=0;i<34;i++){{
13713        (function(idx){{
13714          var el=document.createElement('span');el.className='code-particle';
13715          el.textContent=snips[idx%snips.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
13716          el.style.left=(Math.random()*94+2).toFixed(1)+'%';
13717          el.style.top=(Math.random()*88+6).toFixed(1)+'%';
13718          el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
13719          el.style.setProperty('--op',(Math.random() * 0.096 + 0.06).toFixed(3));
13720          el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
13721          el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
13722          container.appendChild(el);
13723        }})(i);
13724      }}
13725    }}
13726
13727    // ── Watermarks ───────────────────────────────────────────────────────────
13728    var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13729    if(wms.length){{
13730      var placed=[];
13731      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;}}
13732      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];}}
13733      var half=Math.floor(wms.length/2);
13734      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;}});
13735    }}
13736
13737    // ── Settings / colour scheme modal ───────────────────────────────────────
13738    (function(){{
13739      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'}}];
13740      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);}});}}
13741      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
13742      function init(){{
13743        var btn=document.getElementById('settings-btn');if(!btn)return;
13744        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13745        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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
13746        document.body.appendChild(m);
13747        var g=document.getElementById('scheme-grid');
13748        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);}});
13749        var cl=document.getElementById('settings-close-btn');
13750        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');}});
13751        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13752        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13753      }}
13754      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13755    }})();
13756
13757    // ── Timezone support for scan timestamps ─────────────────────────────────
13758    (function(){{
13759      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);}};
13760      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'';}}}};
13761      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);}});}};
13762      var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
13763      window.applyTz(storedTz);
13764      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);}});}}}}
13765      if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
13766    }})();
13767
13768    // ── Data ────────────────────────────────────────────────────────────────
13769    var POINTS={points_json};
13770    var FILES={file_matrix_json};
13771    var N={n};
13772
13773    // ── fmt helper ───────────────────────────────────────────────────────────
13774    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();}}
13775    function fmtFull(n){{return Number(n).toLocaleString();}}
13776    function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
13777
13778    // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
13779    function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
13780    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));}}
13781    function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
13782    function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
13783
13784    // ── Timeline chart ───────────────────────────────────────────────────────
13785    var activeMetric='code';
13786    var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
13787    var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
13788
13789    function renderChart(metric){{
13790      var svg=document.getElementById('mc-chart');if(!svg)return;
13791      var W=svg.getBoundingClientRect().width||800,H=280;
13792      svg.setAttribute('height',H);
13793      var pad={{l:62,r:20,t:32,b:72}};
13794      var dark=document.body.classList.contains('dark-theme');
13795      var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
13796      var valid=pts.filter(function(v){{return v!=null;}});
13797      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;}}
13798      var minV=0,maxV=Math.max.apply(null,valid);
13799      if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
13800      var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
13801      function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
13802      function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
13803      var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
13804      var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
13805      var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
13806      var parts=[];
13807      parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
13808      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>');}}
13809      var areaD='M '+xOf(0)+' '+(pad.t+plotH);
13810      var lineD='';var firstPt=true;
13811      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);}}}}
13812      areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
13813      parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
13814      parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
13815      for(var i=0;i<N;i++){{
13816        if(pts[i]==null)continue;
13817        var cx=xOf(i),cy=yOf(pts[i]);
13818        var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
13819        var hasTag=p.tags&&p.tags.length>0;
13820        // Permanent Y-value label above the dot
13821        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>');
13822        parts.push('<circle class="sx-9463ff47" 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"  data-run-id="'+p.run_id+'"/>');
13823        var xanchor=i===0?'start':i===N-1?'end':'middle';
13824        // X-axis label at 2× the original size (18 px)
13825        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>');
13826      }}
13827      parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
13828      svg.setAttribute('viewBox','0 0 '+W+' '+H);
13829      svg.innerHTML=parts.join('');
13830      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');}});
13831      // ── Interactive hover: vertical crosshair + tooltip ───────────────────
13832      svg.onmousemove=function(e){{
13833        var rect=svg.getBoundingClientRect();
13834        var scaleX=W/rect.width;
13835        var mouseX=(e.clientX-rect.left)*scaleX;
13836        var nearest=-1,minDist=Infinity;
13837        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;}}}}
13838        if(nearest<0)return;
13839        var nc=xOf(nearest),ny=yOf(pts[nearest]);
13840        var xhair=svg.querySelector('.mc-xhair');
13841        if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
13842        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"/>';
13843        var tt=document.getElementById('mc-ic-tt');if(!tt)return;
13844        var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
13845        tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span class="sx-0819fd61" >'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
13846        var bx=rect.left+(nc/W*rect.width)+18;
13847        if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
13848        tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
13849      }};
13850      svg.onmouseleave=function(){{
13851        var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
13852        var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
13853      }};
13854    }}
13855
13856    function escHtml(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
13857
13858    document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
13859      btn.addEventListener('click',function(){{
13860        activeMetric=this.dataset.metric;
13861        document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
13862        this.classList.add('active');
13863        renderChart(activeMetric);
13864      }});
13865    }});
13866    if(typeof ResizeObserver!=='undefined'){{
13867      new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
13868    }}
13869    renderChart(activeMetric);
13870
13871    // ── File matrix table ────────────────────────────────────────────────────
13872    var activeStatus='';
13873    var currentPage=1;
13874    var perPage=25;
13875    var mcSortCol=null,mcSortAsc=true;
13876
13877    function getFiltered(){{
13878      var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
13879      if(!mcSortCol)return data;
13880      var asc=mcSortAsc;
13881      return data.slice().sort(function(a,b){{
13882        var va,vb;
13883        if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
13884        else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
13885        else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
13886        else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
13887        else{{return 0;}}
13888        if(asc)return va<vb?-1:va>vb?1:0;
13889        return va<vb?1:va>vb?-1:0;
13890      }});
13891    }}
13892
13893    function renderFilePage(){{
13894      var filtered=getFiltered();
13895      var total=filtered.length;
13896      var totalPages=Math.max(1,Math.ceil(total/perPage));
13897      if(currentPage>totalPages)currentPage=totalPages;
13898      var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
13899      var tbody=document.getElementById('file-tbody');if(!tbody)return;
13900      var rows=[];
13901      for(var i=start;i<end;i++){{
13902        var f=filtered[i];
13903        var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
13904        cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
13905        cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
13906        for(var j=0;j<N;j++){{
13907          var cv=f.c[j];
13908          cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
13909          if(j<N-1){{
13910            var dv=f.d[j+1];
13911            cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
13912              (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
13913          }}
13914        }}
13915        var tc=f.t;
13916        cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
13917        rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
13918      }}
13919      tbody.innerHTML=rows.join('');
13920
13921      var info=document.getElementById('pg-info');
13922      if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
13923      renderPgBtns(totalPages);
13924    }}
13925
13926    function renderPgBtns(totalPages){{
13927      var wrap=document.getElementById('pg-btns');if(!wrap)return;
13928      var btns=[];
13929      function mkBtn(label,page,active,disabled){{
13930        var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
13931        return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
13932      }}
13933      btns.push(mkBtn('&#8249;',currentPage-1,false,currentPage<=1));
13934      var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
13935      if(s>1)btns.push(mkBtn('1',1,false,false));
13936      if(s>2)btns.push('<span class="pg-btn sx-d4e8f245" >&hellip;</span>');
13937      for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
13938      if(e<totalPages-1)btns.push('<span class="pg-btn sx-d4e8f245" >&hellip;</span>');
13939      if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
13940      btns.push(mkBtn('&#8250;',currentPage+1,false,currentPage>=totalPages));
13941      wrap.innerHTML=btns.join('');
13942      wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
13943        b.addEventListener('click',function(){{
13944          var pg=parseInt(this.dataset.pg,10);
13945          if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
13946        }});
13947      }});
13948    }}
13949
13950    // Tab filter
13951    document.querySelectorAll('.tab-btn').forEach(function(btn){{
13952      btn.addEventListener('click',function(){{
13953        activeStatus=this.dataset.status||'';
13954        currentPage=1;
13955        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
13956        this.classList.add('active');
13957        renderFilePage();
13958      }});
13959    }});
13960
13961    // Per-page selector
13962    var ppSel=document.getElementById('per-page-sel');
13963    if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
13964
13965    // ── Column header sort ───────────────────────────────────────────────────
13966    Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
13967      th.addEventListener('click',function(){{
13968        var col=th.dataset.sortCol;
13969        if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
13970        Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
13971          var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
13972        }});
13973        th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
13974        var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'&#8593;':'&#8595;';
13975        currentPage=1;renderFilePage();
13976      }});
13977    }});
13978
13979    // Reset button also clears sort
13980    var mcResetBtn=document.getElementById('mc-file-reset-btn');
13981    if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
13982      mcSortCol=null;mcSortAsc=true;
13983      Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
13984        var si=t.querySelector('.sort-icon');if(si)si.innerHTML='&#8597;';t.classList.remove('sort-asc','sort-desc');
13985      }});
13986      activeStatus='';currentPage=1;
13987      document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
13988      var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
13989      renderFilePage();
13990    }});
13991
13992    renderFilePage();
13993
13994    // ── CSV export ───────────────────────────────────────────────────────────
13995    var exportBtn=document.getElementById('export-csv-btn');
13996    if(exportBtn)exportBtn.addEventListener('click',function(){{
13997      var header=['File','Language','Status'];
13998      for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
13999      header.push('Net Delta');
14000      var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
14001      var filtered=getFiltered();
14002      filtered.forEach(function(f){{
14003        var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
14004        for(var j=0;j<N;j++){{
14005          cols.push(f.c[j]!=null?f.c[j]:'');
14006          if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
14007        }}
14008        cols.push(f.t);
14009        rows.push(cols.join(','));
14010      }});
14011      var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
14012      var a=document.createElement('a');a.href=URL.createObjectURL(blob);
14013      a.download=mcExportName('csv');a.click();
14014    }});
14015
14016    // ── File matrix extra export buttons ─────────────────────────────────────
14017    (function(){{
14018      var resetBtn=document.getElementById('mc-file-reset-btn');
14019      if(resetBtn)resetBtn.addEventListener('click',function(){{
14020        activeStatus='';currentPage=1;
14021        document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
14022        var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
14023        renderFilePage();
14024      }});
14025
14026      // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
14027      function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
14028      function mcMakeXlsx(fname){{
14029        var filtered=getFiltered();
14030        var enc=new TextEncoder();
14031        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;}}
14032        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;}}
14033        function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
14034        function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
14035        var ss=[],si={{}};
14036        function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
14037        function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
14038        function WS(){{
14039          var R=0,buf=[];
14040          function cl(c){{return String.fromCharCode(65+c);}}
14041          function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
14042          function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
14043          function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
14044          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>';}}
14045          return{{sc:sc,nc:nc,row:row,xml:xml}};
14046        }}
14047        function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
14048        var proj=mcExportProj();
14049        // \u2500\u2500 Summary sheet \u2500\u2500
14050        var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
14051        r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
14052        r1(s1(0,proj,2));
14053        var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
14054        r1(s1(0,firstTs+' \u2192 '+lastTs+'  ('+N+' scans)',2));
14055        r1('');
14056        r1(s1(0,'SCAN SUMMARY',8));
14057        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));
14058        POINTS.forEach(function(p,i){{
14059          var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
14060          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));
14061        }});
14062        r1('');
14063        if(POINTS.length>1){{
14064          var pf=POINTS[0],pl=POINTS[POINTS.length-1];
14065          r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
14066          r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
14067          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)));}};
14068          nr('Code Lines',pf.code,pl.code);
14069          nr('Comment Lines',pf.comments,pl.comments);
14070          nr('Files Analyzed',pf.files,pl.files);
14071          nr('Tests',pf.tests,pl.tests);
14072          r1('');
14073        }}
14074        var cMod=0,cAdd=0,cRem=0,cUnch=0;
14075        FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
14076        var totF=FILES.length||1;
14077        function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
14078        r1(s1(0,'FILE CHANGES',8));
14079        r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
14080        r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
14081        r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
14082        r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
14083        r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
14084        r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
14085        var lm={{}};
14086        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;}});
14087        var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
14088        if(langs.length){{
14089          r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
14090          r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
14091          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)));}});
14092        }}
14093        var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
14094        // \u2500\u2500 File Delta sheet \u2500\u2500
14095        var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
14096        var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
14097        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);}}
14098        hcells+=s2(hc,'Net Delta',3);
14099        r2(hcells);
14100        filtered.forEach(function(f){{
14101          var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
14102          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));}}}}
14103          var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
14104          r2(cells);
14105        }});
14106        var ncols=3+N+(N-1)+1;
14107        var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
14108        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>';
14109        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
14110        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>',
14111          '_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>',
14112          '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>',
14113          '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>',
14114          '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>',
14115          'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
14116        var zparts=[],zcds=[],zoff=0,znf=0;
14117        ['[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){{
14118          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
14119          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]);
14120          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);
14121          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));
14122          var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
14123          zoff+=entry.length;znf++;
14124        }});
14125        var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
14126        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]);
14127        var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
14128        zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
14129        zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
14130        out.set(new Uint8Array(eocd),pos);
14131        var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
14132        var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
14133      }}
14134
14135      var xlsBtn=document.getElementById('mc-file-xls-btn');
14136      if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
14137
14138      // File matrix HTML export — interactive: sort by column, filter by status
14139      function mcFileBuildHtml(){{
14140        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
14141        var hdrs=['File','Language','Status'];
14142        for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
14143        hdrs.push('Net \u0394');
14144        var SI=2;
14145        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;}});
14146        var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
14147        var cnt={{all:allRows.length}};
14148        allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
14149        var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
14150        var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
14151          '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
14152          '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
14153          '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
14154          '.sub{{font-size:12px;color:#99aabb;}}'+
14155          '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
14156          '.wr{{padding:16px 20px;}}'+
14157          '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
14158          '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
14159          '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
14160          '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
14161          '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
14162          'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
14163          'thead tr{{background:#1a2035;}}'+
14164          '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;}}'+
14165          'th:hover{{background:#2a3050;}}'+
14166          'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
14167          'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
14168          'tr:nth-child(even) td{{background:#faf7f4;}}'+
14169          'tr:hover td{{background:#f5f0ea;}}'+
14170          '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
14171          '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
14172        var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
14173        var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
14174          (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
14175          (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
14176          (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
14177          (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
14178        var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
14179          'function fc(v,ci){{if(v==null)return"&mdash;";var s=String(v);'+
14180          'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"&mdash;";}}'+
14181          '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>";}}'+
14182          'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
14183          'return s.length>80?"<abbr title=\\""+s.replace(/"/g,"&quot;")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
14184          'function esc(s){{return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}}'+
14185          'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
14186          'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
14187          'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
14188          '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("")'+
14189          '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
14190          'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
14191          'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
14192          'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
14193          'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
14194          'sd=(sc===ci)?-sd:1;sc=ci;'+
14195          'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
14196          'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
14197          'render();';
14198        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
14199          '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
14200          '<div class="sub">{project_label} &middot; {n} scans<\/div><\/div>'+
14201          '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
14202          '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
14203          '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
14204          '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
14205          '<script>'+inlineJs+'<\/script><\/body><\/html>';
14206      }}
14207
14208      var htmlBtn=document.getElementById('mc-file-html-btn');
14209      if(htmlBtn)htmlBtn.addEventListener('click',function(){{
14210        var h=mcFileBuildHtml();
14211        var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
14212        var a=document.createElement('a');a.href=URL.createObjectURL(blob);
14213        a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
14214      }});
14215
14216      var pdfBtn=document.getElementById('mc-file-pdf-btn');
14217      if(pdfBtn)pdfBtn.addEventListener('click',function(){{
14218        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
14219      }});
14220    }})();
14221
14222    // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
14223    (function(){{
14224      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
14225      // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
14226      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
14227      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
14228      function fmt2(n){{return Number(n).toLocaleString();}}
14229      function px(n){{return Math.round(n);}}
14230      var _tt=document.getElementById('mc-ic-tt');
14231      function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
14232      function addTT(el){{
14233        if(!el)return;
14234        el.addEventListener('mouseover',function(e){{
14235          var t=e.target.closest('[data-ttl]');
14236          if(t&&_tt){{
14237            var ttl=t.getAttribute('data-ttl');
14238            _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
14239            _tt.style.display='block';mvTT(e);
14240            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14241            el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
14242          }} else {{
14243            if(_tt)_tt.style.display='none';
14244            el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14245          }}
14246        }});
14247        el.addEventListener('mouseleave',function(){{
14248          if(_tt)_tt.style.display='none';
14249          el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14250        }});
14251        el.addEventListener('mousemove',function(e){{mvTT(e);}});
14252      }}
14253      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';}}
14254      var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
14255      function buildCharts(){{
14256        if(N<2)return;
14257        var cs=getComputedStyle(document.body);
14258        function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
14259        var textCol=cv('--text','#43342d');
14260        var mutedCol=cv('--muted','#7b675b');
14261        var gFill=cv('--muted-2','#a08777');
14262        var LGY=cv('--line','#e6d0bf');
14263        var axisCol=cv('--line-strong','#d8bfad');
14264        var surf2col=cv('--surface-2','#f4ede4');
14265        var surfCol=cv('--surface','#fff8f0');
14266        var p0=POINTS[0],pLast=POINTS[N-1];
14267        var dark=document.body.classList.contains('dark-theme');
14268        var FADE=dark?'#524238':'#e6d0bf';
14269        var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
14270        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;}}
14271      var c1mets=[
14272        {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
14273        {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
14274        {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
14275      ];
14276      var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
14277      // Code Metrics chart — grows to fill the height its grid row settled to (the
14278      // Language Code Delta sibling usually drives that), so it never sits short at
14279      // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
14280      function drawC1(){{
14281        var C1W=620,C1H=200;
14282        var c1host=document.getElementById('mc-ic-c1');
14283        var c1card=c1host?c1host.closest('.ic-card'):null;
14284        if(c1host&&c1card&&c1host.clientWidth>0){{
14285          var avW=c1host.clientWidth;
14286          var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
14287          var wantH=availPx*C1W/avW;
14288          if(wantH>C1H)C1H=wantH;
14289        }}
14290        var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
14291        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
14292        for(var gi=1;gi<=4;gi++){{
14293          var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
14294          c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
14295          c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
14296        }}
14297        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
14298        c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
14299        c1mets.forEach(function(m,i){{
14300          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
14301          var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
14302          c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
14303          c1+='<rect class="sx-83ac1cee"'+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" />';
14304          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>';
14305          c1+='<rect class="sx-83ac1cee"'+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" />';
14306          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>';
14307          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>';
14308          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>';
14309        }});
14310        c1+='</svg>';
14311        return c1;
14312      }}
14313      // Chart 2: Delta by Metric (net delta first scan to last)
14314      var mets=[
14315        {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
14316        {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
14317        {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
14318      ];
14319      var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
14320      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;
14321      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
14322      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
14323      mets.forEach(function(m,i){{
14324        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);
14325        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>';
14326        c2+='<rect class="sx-83ac1cee"'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" />';
14327        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>';}}
14328        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>';}}
14329      }});
14330      c2+='</svg>';
14331      // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
14332      var lm={{}};
14333      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;}});
14334      var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
14335      function drawC3(){{
14336        if(!langs.length)return'';
14337        var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
14338        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;
14339        var c3host=document.getElementById('mc-ic-c3');
14340        var c3card=document.getElementById('mc-ic-lang-card');
14341        var C3H=langs.length*30+24;
14342        if(c3host&&c3card&&c3host.clientWidth>0){{
14343          var avW=c3host.clientWidth;
14344          var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
14345          var wantH=availPx*C3W/avW;
14346          if(wantH>C3H)C3H=wantH;
14347        }}
14348        var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
14349        var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
14350        c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
14351        langs.forEach(function(l,i){{
14352          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);
14353          c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
14354          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"/>';
14355          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>';}}
14356          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>';}}
14357          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>';
14358        }});
14359        c3+='</svg>';
14360        return c3;
14361      }}
14362      // Chart 4: File Change Distribution (donut left, legend right, % on slices)
14363      var fm=0,fa=0,fr=0,fu=0;
14364      FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
14365      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;}});
14366      var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
14367      var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
14368      function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
14369      var c4='<svg class="sx-b5aafb81" viewBox="0 0 '+C4W+' '+C4H+'" width="100%"  xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
14370      if(segs.length===1){{
14371        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"/>';
14372        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
14373        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>';
14374      }} else {{
14375        segs.forEach(function(s){{
14376          var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
14377          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);
14378          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);
14379          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"/>';
14380          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>';}}
14381          ang4+=sw;
14382        }});
14383      }}
14384      c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
14385      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
14386      var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
14387      segs.forEach(function(s,i){{
14388        var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
14389        c4+='<rect class="sx-83ac1cee"'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" />';
14390        c4+='<text class="sx-83ac1cee"'+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+'" >'+esc(s.l)+'</text>';
14391        c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
14392      }});
14393      c4+='</svg>';
14394      // Inject the fixed-size siblings first, then size Code Metrics (c1) and
14395      // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
14396      // once at natural height to seed the row, then both are filled to the row the
14397      // grid settled to, so neither sits short at the top of an over-tall cell.
14398      var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
14399      var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
14400      var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
14401      var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
14402      var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p class="sx-90171b6d" >No language delta.</p>';
14403      if(e1)e1.innerHTML=drawC1();
14404      }}
14405      buildCharts();
14406      renderInlineCharts=buildCharts;
14407      ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
14408      (function(){{
14409        var ov=document.getElementById('ic-svg-modal-ov');
14410        var body=document.getElementById('ic-svg-modal-body');
14411        var ttl=document.getElementById('ic-svg-modal-title');
14412        var closeBtn=document.getElementById('ic-svg-modal-close');
14413        if(!ov||!body)return;
14414        function close(){{ov.classList.remove('open');body.innerHTML='';}}
14415        function open(srcId,title){{
14416          var src=document.getElementById(srcId);if(!src)return;
14417          ttl.textContent=title||'';
14418          var card=src.closest('.ic-card');
14419          var legHtml='';
14420          if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg sx-16dbf2a3" >'+leg.innerHTML+'</div>';}}
14421          body.innerHTML=legHtml+src.innerHTML;
14422          var svg=body.querySelector('svg');
14423          if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
14424          addTT(body);
14425          ov.classList.add('open');
14426        }}
14427        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
14428          btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
14429        }});
14430        if(closeBtn)closeBtn.addEventListener('click',close);
14431        ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
14432        document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
14433      }})();
14434
14435      // HTML legend hover → highlight matching SVG bars within the SAME card only
14436      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
14437        var metric=leg.getAttribute('data-highlight');
14438        var parentCard=leg.closest('.ic-card');
14439        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
14440        if(!chartEl)return;
14441        leg.addEventListener('mouseenter',function(){{
14442          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
14443            if(x.getAttribute('data-ttl').indexOf(metric)===0){{
14444              x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
14445              x.style.opacity='1';
14446            }} else {{
14447              x.style.opacity='0.28';
14448            }}
14449          }});
14450        }});
14451        leg.addEventListener('mouseleave',function(){{
14452          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14453        }});
14454      }});
14455      // Author handles
14456      document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
14457
14458      // ── Export helpers ────────────────────────────────────────────────────────
14459      // Fetch one image from the server and return a data-URI Promise
14460      function mcFetchUri(path){{
14461        return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
14462          return new Promise(function(res){{
14463            var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
14464          }});
14465        }}).catch(function(){{return '';}});
14466      }}
14467      // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
14468      function mcInlineImgs(html,cb){{
14469        var paths=[],seen={{}};
14470        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
14471        if(!paths.length){{cb(html);return;}}
14472        Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
14473          .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
14474          .catch(function(){{cb(html);}});
14475      }}
14476      // Capture full-page HTML with all table rows visible
14477      function mcRawHtml(pdfMode){{
14478        if(pdfMode)document.body.classList.add('pdf-mode');
14479        var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
14480        var html=window.sxSelfContain(document.documentElement.outerHTML);
14481        perPage=s;currentPage=p;renderFilePage();
14482        if(pdfMode)document.body.classList.remove('pdf-mode');
14483        return html;
14484      }}
14485
14486      // HTML export (full page with inlined images)
14487      function mcDoHtml(btn,fname){{
14488        var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
14489        mcInlineImgs(mcRawHtml(false),function(html){{
14490          var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
14491          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
14492          a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
14493          btn.disabled=false;btn.innerHTML=orig;
14494        }});
14495      }}
14496      // PDF export — comprehensive document-style report: full numbers, all sections
14497      function mcBuildPdfHtml(){{
14498        function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
14499        function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
14500        function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
14501        function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span class="sx-e46b3d3d" >'+s+'</span>':Number(v)<0?'<span class="sx-4e307fd5" >'+s+'</span>':'<span>'+s+'</span>';}}
14502        var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
14503        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
14504        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)));}}
14505        var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
14506        var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
14507        var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
14508        // Header/footer flow in document order (NOT position:fixed) — a fixed
14509        // header repeats every printed page in Chromium and overlaps the content
14510        // below it, swallowing the first rows of pages 2+ and clipping the cards
14511        // on page 1. The table <thead> repeats per page natively, so every row
14512        // stays visible.
14513        var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
14514          '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
14515          '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
14516          '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
14517          '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
14518          '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
14519          '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
14520          '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
14521          '.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;}}'+
14522          '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
14523          '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
14524          '.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;}}'+
14525          '.body{{padding:12px 18px 0;}}'+
14526          '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
14527          '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
14528          '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
14529          '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
14530          '.sec{{margin-bottom:10px;}}'+
14531          '.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;}}'+
14532          'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
14533          '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;}}'+
14534          'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
14535          'tr:nth-child(even) td{{background:#faf8f6;}}';
14536        // ── Metric Progression ────────────────────────────────────────────────
14537        var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
14538        var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
14539        var progHdr='<th>#</th><th>Scan Ref</th><th class="sx-5f326564" >Code Lines</th><th class="sx-5f326564" >Comments</th><th class="sx-5f326564" >Blank Lines</th><th class="sx-5f326564" >Files</th>';
14540        if(hasTests)progHdr+='<th class="sx-5f326564" >Tests</th>';
14541        if(hasCov)progHdr+='<th class="sx-5f326564" >Coverage</th>';
14542        var progRows=POINTS.map(function(pt,i){{
14543          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)));
14544          var r='<tr><td class="sx-bee502d4" >'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
14545            '<td class="sx-5f326564" >'+full(pt.code)+'</td>'+
14546            '<td class="sx-5f326564" >'+full(pt.comments)+'</td>'+
14547            '<td class="sx-5f326564" >'+full(pt.blank)+'</td>'+
14548            '<td class="sx-5f326564" >'+full(pt.files)+'</td>';
14549          if(hasTests)r+='<td class="sx-5f326564" >'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'&mdash;')+'</td>';
14550          if(hasCov)r+='<td class="sx-5f326564" >'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'&mdash;')+'</td>';
14551          return r+'</tr>';
14552        }}).join('');
14553        // ── Scan-to-scan changes ──────────────────────────────────────────────
14554        var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
14555          var prev=POINTS[i];
14556          var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
14557          var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
14558          return '<tr><td class="sx-eb985bc3" >'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
14559            '<td class="sx-5f326564" >'+dHtml(cd)+'</td>'+
14560            '<td class="sx-5f326564" >'+dHtml(cm)+'</td>'+
14561            '<td class="sx-5f326564" >'+dHtml(bl)+'</td>'+
14562            '<td class="sx-5f326564" >'+dHtml(fd)+'</td></tr>';
14563        }}).join(''):'';
14564        // ── File matrix (top 50 by |total delta|) ────────────────────────────
14565        var fmSection='';
14566        if(FILES&&FILES.length){{
14567          // Hard cap on per-scan columns so the table never overflows the page width.
14568          var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
14569          var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
14570          var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
14571          for(var fi=startIdx;fi<N;fi++)fmHdr+='<th class="sx-5f326564" >Scan '+(fi+1)+'</th>';
14572          fmHdr+='<th class="sx-5f326564" >Total \u0394</th>';
14573          var fmRows=topFiles.map(function(f){{
14574            var ss=f.s==='added'?'data-sx-style="color:#2a6846;font-weight:700"':f.s==='removed'?'data-sx-style="color:#b23030;font-weight:700"':'';
14575            var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td class="sx-5f326564" >'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'&mdash;')+'</td>';
14576            cols+='<td class="sx-5f326564" >'+dHtml(Number(f.t))+'</td>';
14577            var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
14578            return '<tr><td class="sx-e22f660d" >'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
14579          }}).join('');
14580          var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
14581          fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
14582            '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
14583        }}
14584        return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
14585          '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
14586          '<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>'+
14587
14588          '<div class="body">'+
14589          '<div class="sg">'+
14590          (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
14591            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Code Lines</div></div>')+
14592          (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
14593            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Latest Files</div></div>')+
14594          (codeDelta!==null?'<div class="sc"><div class="sv" data-sx-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>':
14595            '<div class="sc"><div class="sv">&mdash;</div><div class="sl">Net Code Change</div></div>')+
14596          '<div class="sc"><div class="sv sx-55aba240" >{n}</div><div class="sl">Scans Compared</div></div>'+
14597          '</div>'+
14598          '<div class="sec"><p class="sh">Metric Progression</p>'+
14599          '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
14600          (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
14601          '<table><thead><tr><th class="sx-90500853" >Scans</th>'+
14602          '<th class="sx-5f326564" >Code \u0394</th><th class="sx-5f326564" >Comments \u0394</th>'+
14603          '<th class="sx-5f326564" >Blank \u0394</th><th class="sx-5f326564" >Files \u0394</th>'+
14604          '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
14605          fmSection+
14606          '</div>'+
14607          '<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>'+
14608          '</body></html>';
14609      }}
14610      function mcDoPdf(btn){{
14611        window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
14612      }}
14613
14614      var mcHtmlBtn=document.getElementById('mc-export-html-btn');
14615      if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
14616      var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
14617      if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
14618      var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
14619      if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
14620      var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
14621      if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
14622      if(location.protocol==='file:'){{
14623        [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';}}}} );
14624        [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';}}}} );
14625      }}
14626    }})();
14627    // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
14628    (function(){{
14629      function $(id){{return document.getElementById(id);}}
14630      function esc(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
14631      function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
14632      function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
14633      function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
14634      function openModal(idx){{
14635        var ov=$('mc-modal-overlay');if(!ov)return;
14636        var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
14637        if(idx<0||idx>=N)return;
14638        var pt=POINTS[idx];
14639        titleEl.textContent='Scan '+(idx+1);
14640        var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
14641        subEl.textContent=lbl;
14642        var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
14643          '<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>'+
14644          '<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>'+
14645          '<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>'+
14646          '<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>'+
14647          (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>':'')+
14648          (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>':'')+
14649          '</div></div>';
14650        var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
14651          (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>':'')+
14652          (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
14653          (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
14654          (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>':'')+
14655          (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>':'')+
14656          (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>':'')+
14657          (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>':'')+
14658          '<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>'+
14659          '</div>';
14660        var dHtml='';
14661        if(idx>0){{
14662          var prev=POINTS[idx-1];
14663          var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
14664          dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
14665            '<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" data-sx-style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
14666            '<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" data-sx-style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
14667            '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" data-sx-style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
14668            '</div></div>';
14669        }}
14670        bodyEl.innerHTML=sHtml+iHtml+dHtml;
14671        ov.classList.add('open');document.body.style.overflow='hidden';
14672      }}
14673      function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
14674      // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
14675      document.addEventListener('click',function(e){{
14676        if(!e.target||!e.target.closest)return;
14677        if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
14678        if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
14679        var card=e.target.closest('.mc-card');
14680        if(!card)return;
14681        if(e.target.closest('a'))return;
14682        var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
14683        var i=cards.indexOf(card);
14684        if(i>=0)openModal(i);
14685      }});
14686      document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
14687      // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
14688      var statTip=null;
14689      document.addEventListener('mousemove',function(e){{
14690        var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
14691        if(!box){{if(statTip)statTip.style.display='none';return;}}
14692        if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
14693        var tip=box.getAttribute('data-tip')||'';
14694        if(statTip.textContent!==tip)statTip.textContent=tip;
14695        statTip.style.display='block';
14696        var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
14697        if(x+w>window.innerWidth-8)x=e.clientX-w-14;
14698        if(y+h>window.innerHeight-8)y=e.clientY-h-16;
14699        statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
14700      }});
14701      (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');}})();
14702    }})();
14703  }})();
14704  </script>
14705  <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]';
14706  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;}}
14707  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>
14708  <!-- Scan card detail modal -->
14709  <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
14710    <div class="mc-modal" id="mc-modal">
14711      <div class="mc-modal-head">
14712        <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
14713        <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">&#10005;</button>
14714      </div>
14715      <div class="mc-modal-body" id="mc-modal-body"></div>
14716    </div>
14717  </div>
14718  {toast_assets}
14719</body>
14720</html>"#,
14721        project_label = html_escape(project_label),
14722        n = n,
14723        scan_strip = scan_strip,
14724        mc_strip_class = mc_strip_class,
14725        metrics_thead = metrics_thead,
14726        metrics_tbody = metrics_tbody,
14727        file_col_headers = file_col_headers,
14728        total_files = total_files,
14729        files_modified = files_modified,
14730        files_added = files_added,
14731        files_removed = files_removed,
14732        files_unchanged = files_unchanged,
14733        points_json = points_json,
14734        file_matrix_json = file_matrix_json,
14735        nav_compare_active = nav_compare_active,
14736        version = version,
14737        csp_nonce = csp_nonce,
14738        scope_bar_html = scope_bar_html,
14739        scope_label = scope_label,
14740        loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
14741    )
14742}
14743
14744// ── Trend report page ─────────────────────────────────────────────────────────
14745// Protected. Interactive time-series chart page that loads scan history via
14746// /api/metrics/history and renders a vanilla-SVG line chart.
14747//
14748// GET /trend-reports
14749
14750#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
14751async fn trend_report_handler(
14752    State(state): State<AppState>,
14753    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
14754) -> Response {
14755    auto_scan_watched_dirs(&state).await;
14756
14757    let watched_dirs_list: Vec<String> = {
14758        let wd = state.watched_dirs.lock().await;
14759        wd.dirs.iter().map(|p| p.display().to_string()).collect()
14760    };
14761
14762    // Collect distinct project roots for the root selector dropdown.
14763    let roots: Vec<String> = {
14764        let reg = state.registry.lock().await;
14765        let mut seen = std::collections::BTreeSet::new();
14766        reg.entries
14767            .iter()
14768            .flat_map(|e| e.input_roots.iter().cloned())
14769            .filter(|r| seen.insert(r.clone()))
14770            .collect()
14771    };
14772
14773    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
14774    let nonce = &csp_nonce;
14775    let version = env!("CARGO_PKG_VERSION");
14776    let toast_assets = sloc_toast_assets(nonce);
14777
14778    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
14779    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
14780    // of interactive controls — folder watching is managed by the host administrator.
14781    let watched_dirs_html: String = if state.server_mode {
14782        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()
14783    } else {
14784        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
14785            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
14786                .to_string()
14787        } else {
14788            watched_dirs_list
14789                .iter()
14790                .fold(String::new(), |mut s, d| {
14791                    use std::fmt::Write as _;
14792                    let escaped =
14793                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
14794                    write!(
14795                        s,
14796                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form class="sx-043808a9" method="POST" action="/watched-dirs/remove" ><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>"#
14797                    ).expect("write to String is infallible");
14798                    s
14799                })
14800        };
14801        format!(
14802            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 class="sx-043808a9" method="POST" action="/watched-dirs/refresh" ><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
14803        )
14804    };
14805
14806    let html = format!(
14807        r##"<!doctype html>
14808<html lang="en">
14809<head>
14810  <meta charset="utf-8" />
14811  <meta name="viewport" content="width=device-width, initial-scale=1" />
14812  <title>OxideSLOC | Trend Reports</title>
14813  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14814  <link rel="stylesheet" href="/static/app.css">
14815  <script src="/static/app.js"></script>
14816  <style nonce="{nonce}">
14817    :root {{
14818      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
14819      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14820      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14821      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14822      --info-bg:#eef3ff; --info-text:#4467d8;
14823    }}
14824    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
14825    *{{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;}}
14826    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
14827    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
14828    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
14829    @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));}}}}
14830    .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);}}
14831    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
14832    .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));}}
14833    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
14834    .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;}}
14835    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
14836    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
14837    @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; }} }}
14838    .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;}}
14839    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
14840    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
14841    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
14842    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
14843    .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;}}
14844    .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;}}
14845    .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;}}
14846    .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;}}
14847    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
14848    .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);}}
14849    .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;}}
14850    .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;}}
14851    .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;}}
14852    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
14853    .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;}}
14854    .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);}}
14855    .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;}}
14856    .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;}}
14857    .tz-select:focus{{border-color:var(--oxide);}}
14858    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
14859    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
14860    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
14861    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
14862    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
14863    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
14864    .trend-title-block{{flex:1;min-width:0;}}
14865    .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;}}
14866    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14867    .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;}}
14868    .chart-select:focus{{border-color:var(--accent);}}
14869    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
14870    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
14871    .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);}}
14872    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
14873    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
14874    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
14875    .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);}}
14876    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14877    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14878    .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;}}
14879    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
14880    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
14881    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
14882    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14883    .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;}}
14884    .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14885    .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;}}
14886    .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);}}
14887    .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;}}
14888    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
14889    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
14890    .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);}}
14891    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
14892    .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;}}
14893    .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;}}
14894    .data-table tr:last-child td{{border-bottom:none;}}
14895    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
14896    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
14897    .table-wrap{{width:100%;overflow-x:auto;}}
14898    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
14899    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
14900    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
14901    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
14902    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
14903    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
14904    .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;}}
14905    .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;}}
14906    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
14907    .pagination-info{{font-size:13px;color:var(--muted);}}
14908    .pagination-btns{{display:flex;gap:6px;}}
14909    .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;}}
14910    .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;}}
14911    #scan-history-table col:nth-child(1){{width:155px;}}
14912    #scan-history-table col:nth-child(2){{width:240px;}}
14913    #scan-history-table col:nth-child(3){{width:82px;}}
14914    #scan-history-table col:nth-child(4){{width:82px;}}
14915    #scan-history-table col:nth-child(5){{width:90px;}}
14916    #scan-history-table col:nth-child(6){{width:90px;}}
14917    #scan-history-table col:nth-child(7){{width:88px;}}
14918    #scan-history-table col:nth-child(8){{width:150px;}}
14919    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
14920    .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;}}
14921    .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;}}
14922    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
14923    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
14924    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14925    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14926    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14927    .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;}}
14928    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14929    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14930    .watched-chip-rm:hover{{color:var(--oxide);}}
14931    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14932    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14933    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14934    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14935    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
14936    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
14937    a.run-link:hover{{text-decoration:underline;}}
14938    .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);}}
14939    .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);}}
14940    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
14941    .metric-num{{font-weight:700;color:var(--text);}}
14942    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
14943    .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;}}
14944    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
14945    .btn.primary:hover{{opacity:.9;}}
14946    .rpt-btn{{min-width:58px;justify-content:center;}}
14947    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
14948    .report-cell{{overflow:visible!important;white-space:normal!important;}}
14949    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
14950    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
14951    .submod-details summary::-webkit-details-marker{{display:none;}}
14952    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
14953    .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;}}
14954    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
14955    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
14956    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
14957    .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;}}
14958    .export-btn:hover{{background:var(--line);}}
14959    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14960    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14961    .site-footer a{{color:var(--muted);}}
14962    .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;}}
14963    .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;}}
14964    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
14965    /* Modal system (Retention Policy / Clean-up) */
14966    .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;}}
14967    @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
14968    .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);}}
14969    .tr-modal{{background:rgba(255,255,255,0.90);}}
14970    body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
14971    @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
14972    .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
14973    .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);}}
14974    .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
14975    .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
14976    .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
14977    .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
14978    .tr-modal-body{{padding:22px 30px;}}
14979    .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
14980    .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;}}
14981    .tr-btn:hover{{transform:translateY(-1px);}}
14982    .tr-btn:active{{transform:translateY(0);}}
14983    .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
14984    .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
14985    .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
14986    .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
14987    .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
14988    .tr-btn-secondary:hover{{background:var(--line);}}
14989    .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
14990    .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
14991  </style>
14992</head>
14993<body>
14994  <div class="background-watermarks" aria-hidden="true">
14995    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14996    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14997    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14998    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14999    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15000    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15001  </div>
15002  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15003  <div class="top-nav">
15004    <div class="top-nav-inner">
15005      <a class="brand" href="/">
15006        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15007        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
15008      </a>
15009      <div class="nav-right">
15010        <a class="nav-pill" href="/">Home</a>
15011        <div class="nav-dropdown">
15012          <a href="/view-reports" class="nav-dropdown-btn sx-8c38ef73" >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>
15013          <div class="nav-dropdown-menu">
15014            <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>
15015          </div>
15016        </div>
15017        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15018        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15019        <div class="nav-dropdown">
15020          <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>
15021          <div class="nav-dropdown-menu">
15022            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
15023            <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>
15024          </div>
15025        </div>
15026        <div class="server-status-wrap" id="server-status-wrap">
15027          <div class="nav-pill server-online-pill" id="server-status-pill">
15028            <span class="status-dot" id="status-dot"></span>
15029            <span id="server-status-label">Server</span>
15030            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
15031          </div>
15032          <div class="server-status-tip">
15033            OxideSLOC is running — accessible on your network.
15034            <span class="sx-238af6bc" id="server-tip-ping" ></span>
15035          </div>
15036        </div>
15037        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15038          <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>
15039        </button>
15040        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15041          <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>
15042          <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>
15043        </button>
15044      </div>
15045    </div>
15046  </div>
15047
15048  <div class="page">
15049    {watched_dirs_html}
15050    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
15051      <div class="scan-overlay-card">
15052        <div class="scan-spinner"></div>
15053        <div class="scan-overlay-text">Scanning folder…</div>
15054        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
15055      </div>
15056    </div>
15057    <style nonce="{nonce}">
15058    .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);}}
15059    .scan-overlay.active{{display:flex;}}
15060    .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;}}
15061    .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;}}
15062    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
15063    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
15064    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
15065    </style>
15066    <div class="summary-strip" id="trend-stats"></div>
15067    <div class="panel">
15068      <div class="trend-header">
15069        <div class="trend-title-block">
15070          <h1>Trend Reports</h1>
15071          <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>
15072          <span class="chart-hint-inline">
15073            <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>
15074            Click a dot or row to view its full report &nbsp;·&nbsp; <span class="dot sx-52f7d121" ></span>&thinsp;regular scan &nbsp;<span class="dot sx-728e7e7c" ></span>&thinsp;tagged / release scan
15075          </span>
15076        </div>
15077        <div class="chart-actions">
15078          <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
15079            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
15080            Retention Policy
15081          </button>
15082          <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
15083            <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>
15084            Clean up old runs
15085          </button>
15086          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
15087            <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>
15088            Export Excel
15089          </button>
15090          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
15091            <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>
15092            Export PNG
15093          </button>
15094          <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
15095            <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>
15096            Export PDF
15097          </button>
15098        </div>
15099      </div>
15100
15101      <div class="controls-centered">
15102        <label>Project Root:
15103          <select class="chart-select" id="root-sel">
15104            <option value="">All projects</option>
15105          </select>
15106        </label>
15107        <label>Y Metric:
15108          <select class="chart-select" id="y-sel">
15109            <option value="code_lines">Code Lines</option>
15110            <option value="comment_lines">Comment Lines</option>
15111            <option value="blank_lines">Blank Lines</option>
15112            <option value="physical_lines">Physical Lines</option>
15113            <option value="files_analyzed">Files Analyzed</option>
15114          </select>
15115        </label>
15116        <label>X Axis:
15117          <select class="chart-select" id="x-sel">
15118            <option value="time">By Time</option>
15119            <option value="commit" selected>By Commit</option>
15120            <option value="release">By Release</option>
15121            <option value="tag">Tagged Commits</option>
15122          </select>
15123        </label>
15124        <label class="sx-d0466aa3" id="submodule-label" >Submodule:
15125          <select class="chart-select" id="sub-sel">
15126            <option value="">All (project total)</option>
15127          </select>
15128        </label>
15129        <label>Chart Size:
15130          <select class="chart-select" id="scale-sel">
15131            <option value="0.75">Compact</option>
15132            <option value="1.2" selected>Normal</option>
15133            <option value="1.38">Large</option>
15134          </select>
15135        </label>
15136        <button class="tr-expand-btn" id="tr-chart-fv-btn">&#x2922; Full View</button>
15137      </div>
15138
15139      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
15140      <div class="sx-fbff25e9" id="data-table-wrap" ></div>
15141    </div>
15142  </div>
15143
15144  <script nonce="{nonce}">
15145    (function() {{
15146      // Theme persistence
15147      var b = document.body;
15148      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
15149      var tgl = document.getElementById('theme-toggle');
15150      if (tgl) tgl.addEventListener('click', function() {{
15151        var d = b.classList.toggle('dark-theme');
15152        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
15153      }});
15154
15155      // Watermark randomizer
15156      (function() {{
15157        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15158        if (!wms.length) return;
15159        var placed = [];
15160        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;}}
15161        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];}}
15162        var half=Math.floor(wms.length/2);
15163        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;}});
15164      }})();
15165
15166      // Code particles
15167      (function() {{
15168        var container = document.getElementById('code-particles');
15169        if (!container) return;
15170        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
15171        for (var i = 0; i < 44; i++) {{
15172          (function(idx) {{
15173            var el = document.createElement('span');
15174            el.className = 'code-particle';
15175            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
15176            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
15177            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
15178            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.108 + 0.072).toFixed(3);
15179            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';
15180            container.appendChild(el);
15181          }})(i);
15182        }}
15183      }})();
15184
15185      // Watched folder picker
15186      (function(){{
15187        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');}};
15188        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);
15189      }})();
15190      (function() {{
15191        var btn = document.getElementById('add-watched-btn');
15192        if (!btn) return;
15193        btn.addEventListener('click', function() {{
15194          fetch('/pick-directory?kind=reports')
15195            .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
15196            .then(function(data) {{
15197              if (!data.cancelled && data.selected_path) {{
15198                var form = document.createElement('form');
15199                form.method = 'POST';
15200                form.action = '/watched-dirs/add';
15201                var ri = document.createElement('input');
15202                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
15203                var fi = document.createElement('input');
15204                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
15205                form.appendChild(ri); form.appendChild(fi);
15206                document.body.appendChild(form);
15207                if (window.__scanOverlay) window.__scanOverlay();
15208                form.submit();
15209              }}
15210            }})
15211            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
15212        }});
15213      }})();
15214
15215      // Settings / color-scheme modal
15216      (function() {{
15217        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'}}];
15218        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);}});}}
15219        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
15220        var btn=document.getElementById('settings-btn');if(!btn)return;
15221        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15222        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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
15223        document.body.appendChild(m);
15224        var g=document.getElementById('scheme-grid');
15225        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);}});
15226        var cl=document.getElementById('settings-close');
15227        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);}});}})();
15228        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');}});
15229        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
15230        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
15231      }})();
15232    }})();
15233
15234    var ROOTS = {roots_json};
15235    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15236    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
15237    var allData = [];
15238
15239    // Populate root selector
15240    var rootSel = document.getElementById('root-sel');
15241    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
15242
15243    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();}}
15244    function fmtFull(n){{return Number(n).toLocaleString();}}
15245    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
15246
15247    // Tooltip
15248    var tt = document.createElement('div');
15249    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);';
15250    document.body.appendChild(tt);
15251    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
15252    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';}}
15253    function hideTT(){{tt.style.display='none';}}
15254    window.addEventListener('blur',function(){{hideTT();}});
15255    document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
15256
15257    function statExact(compact, full){{
15258      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
15259    }}
15260    function statVal(n){{
15261      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
15262    }}
15263
15264    function updateStats(data){{
15265      var statsEl=document.getElementById('trend-stats');
15266      if(!statsEl)return;
15267      if(!data||!data.length){{statsEl.innerHTML='';return;}}
15268      var yKey=document.getElementById('y-sel').value;
15269      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
15270      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
15271      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
15272      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
15273      var absDelta=Math.abs(delta);
15274      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
15275      var deltaExact=statExact(deltaCompact,deltaFull);
15276      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
15277      statsEl.innerHTML=
15278        '<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>'+
15279        '<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>'+
15280        '<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>'+
15281        '<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>';
15282    }}
15283
15284    var subSel = document.getElementById('sub-sel');
15285    var subLabel = document.getElementById('submodule-label');
15286
15287    function populateSubmodules(root){{
15288      if(!subSel||!subLabel)return;
15289      while(subSel.options.length>1)subSel.remove(1);
15290      subSel.value='';
15291      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
15292      fetch(url)
15293        .then(function(r){{return r.json();}})
15294        .then(function(subs){{
15295          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
15296          subs.forEach(function(s){{
15297            var o=document.createElement('option');
15298            o.value=s.name;
15299            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
15300            subSel.appendChild(o);
15301          }});
15302          subLabel.style.display='';
15303        }})
15304        .catch(function(){{subLabel.style.display='none';}});
15305    }}
15306
15307    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
15308
15309    function loadAndRender(){{
15310      var root = rootSel.value;
15311      var sub = subSel ? subSel.value : '';
15312      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
15313      document.getElementById('data-table-wrap').innerHTML='';
15314      var url = '/api/metrics/history?limit=100'
15315        + (root ? '&root='+encodeURIComponent(root) : '')
15316        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
15317      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
15318        allData = data;
15319        render(data);
15320        updateStats(data);
15321      }}).catch(function(){{
15322        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>';
15323      }});
15324    }}
15325
15326    function render(data){{
15327      var yKey = document.getElementById('y-sel').value;
15328      var xMode = document.getElementById('x-sel').value;
15329
15330      // Filter for tag/release mode
15331      var pts = data;
15332      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
15333
15334      // Sort oldest-first for the line chart
15335      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
15336
15337      var wrap = document.getElementById('chart-wrap');
15338      if(!pts.length){{
15339        var emptyMsg = (xMode === 'tag')
15340          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
15341          : 'No scan data found for the selected filters.';
15342        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
15343        renderTable([]);
15344        return;
15345      }}
15346
15347      var scaleEl=document.getElementById('scale-sel');
15348      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
15349      renderTrendInto(wrap, pts, yKey, xMode, sc);
15350      renderTable(pts, yKey);
15351    }}
15352
15353    // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
15354    // Shared by the inline chart and the Full View modal so both render identically.
15355    function renderTrendInto(wrap, pts, yKey, xMode, sc){{
15356      // Fill the container width (like the Chart.js charts) instead of a fixed 900px
15357      // canvas centered with empty margins; Chart Size (sc) drives height + detail.
15358      var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
15359      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;
15360      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
15361
15362      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
15363
15364      var svg='<svg class="sx-c0edbe3d" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'"  xmlns="http://www.w3.org/2000/svg">';
15365      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>';
15366
15367      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
15368
15369      // Grid + Y axis ticks
15370      for(var ti=0;ti<=5;ti++){{
15371        var gy=PT+CH-Math.round(ti/5*CH);
15372        var gv=Math.round(ti/5*maxY);
15373        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
15374        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
15375      }}
15376
15377      // X axis labels (every N-th point to avoid crowding)
15378      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
15379      pts.forEach(function(d,i){{
15380        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
15381        if(i%labelEvery===0||i===pts.length-1){{
15382          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)));
15383          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>';
15384        }}
15385      }});
15386
15387      // Axis label
15388      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
15389      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>';
15390      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>';
15391
15392      // Area fill + line path
15393      var pathD='';
15394      pts.forEach(function(d,i){{
15395        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
15396        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
15397        pathD+=(i===0?'M':'L')+x+','+y;
15398      }});
15399      if(pts.length>1){{
15400        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
15401        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
15402      }}
15403      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
15404
15405      // Data points (clickable) + permanent value labels
15406      var showLabels = pts.length <= 40;
15407      var labelEveryN = pts.length > 20 ? 2 : 1;
15408      pts.forEach(function(d,i){{
15409        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
15410        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
15411        var hasTags=d.tags&&d.tags.length>0;
15412        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
15413        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
15414        svg+='<circle class="trend-pt sx-83ac1cee" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2"  data-idx="'+i+'"/>';
15415        if(showLabels && i%labelEveryN===0){{
15416          var lx=x, ly=y-r-5;
15417          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>';
15418        }}
15419      }});
15420
15421      svg+='</svg>';
15422      wrap.innerHTML=svg;
15423
15424      // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
15425      function lineYAt(mx){{
15426        var n=pts.length;
15427        if(n===0)return PT+CH;
15428        if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
15429        var fx=(mx-PL)/Math.max(CW,1)*(n-1);
15430        if(fx<0)fx=0; if(fx>n-1)fx=n-1;
15431        var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
15432        var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
15433        var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
15434        return y0+t*(y1-y0);
15435      }}
15436
15437      // SVG-level mousemove: show the value tooltip only when the pointer is over the
15438      // gradient fill (inside the chart and at/below the line) — never in the empty
15439      // space above the line. Cursor follows the same rule.
15440      (function(){{
15441        var svgEl=wrap.querySelector('svg');
15442        if(!svgEl)return;
15443        svgEl.addEventListener('mousemove',function(e){{
15444          if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
15445          var rect=svgEl.getBoundingClientRect();
15446          var scaleX=W/Math.max(rect.width,1);
15447          var scaleY=H/Math.max(rect.height,1);
15448          var mouseX=(e.clientX-rect.left)*scaleX;
15449          var mouseY=(e.clientY-rect.top)*scaleY;
15450          var ly=lineYAt(mouseX);
15451          if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
15452          svgEl.style.cursor='pointer';
15453          var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
15454          var d=pts[idx];
15455          var val=Number(d[yKey]);
15456          var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
15457          showTT(e,
15458            '<strong class="sx-b907acdf" >'+esc(lbl)+'</strong>'+
15459            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
15460            '<br><span class="sx-1603cce1" >'+d.timestamp.substring(0,10)+'</span>'
15461          );
15462        }});
15463        svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
15464      }})();
15465
15466      // Attach point tooltips
15467      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
15468        c.addEventListener('mouseover',function(e){{
15469          var d=pts[parseInt(this.dataset.idx)];
15470          var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span class="sx-40777e33" >'+esc(t)+'</span>';}}).join(''):'';
15471          var nearestHtml=d.nearest_tag?'<br>Nearest release: <span class="sx-89081a19" >'+esc(d.nearest_tag)+'</span>':'';
15472          showTT(e,
15473            '<strong class="sx-b907acdf" >'+esc(d.project_label)+'</strong>'+
15474            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
15475            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
15476            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
15477          );
15478          this.setAttribute('r','8');
15479        }});
15480        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
15481        c.addEventListener('mousemove',moveTT);
15482        c.addEventListener('click',function(){{
15483          var d=pts[parseInt(this.dataset.idx)];
15484          if(d.html_url) window.open(d.html_url,'_blank');
15485        }});
15486      }});
15487    }}
15488
15489    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
15490    var shProjFilter='', shBranchFilter='';
15491
15492    function fmtPST(isoStr){{
15493      if(!isoStr)return'';
15494      var d=new Date(isoStr);
15495      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
15496      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);}}
15497      function p(n){{return n<10?'0'+n:String(n);}}
15498      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++;}}}}
15499      var yr=d.getUTCFullYear();
15500      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
15501      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
15502      var isDST=d>=dstStart&&d<dstEnd;
15503      var off=isDST?-7*3600*1000:-8*3600*1000;
15504      var lbl=isDST?'PDT':'PST';
15505      var loc=new Date(d.getTime()+off);
15506      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
15507    }}
15508
15509    function getShRows(){{
15510      var proj=shProjFilter.toLowerCase().trim();
15511      var branch=shBranchFilter;
15512      return shData.filter(function(d){{
15513        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
15514        if(branch&&(d.branch||'')!==branch)return false;
15515        return true;
15516      }});
15517    }}
15518
15519    function renderShPage(){{
15520      var filtered=getShRows();
15521      if(shSortCol){{
15522        filtered.sort(function(a,b){{
15523          var va,vb;
15524          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
15525          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
15526          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
15527          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
15528          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
15529          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
15530        }});
15531      }}
15532      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
15533      shPage=Math.min(shPage,totalPages);
15534      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
15535      var visible=filtered.slice(start,end);
15536      var tbody=document.getElementById('sh-tbody');
15537      if(!tbody)return;
15538      tbody.innerHTML=visible.map(function(d){{
15539        var tsHtml=esc(fmtPST(d.timestamp));
15540        var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span class="sx-eac76940" >&#8212;</span>';
15541        var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span class="sx-eac76940" >&#8212;</span>';
15542        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span class="sx-eac76940" >&#8212;</span>';
15543        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
15544        var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
15545        var reportCell='';
15546        if(d.html_url){{
15547          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
15548          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>';}}
15549          reportCell+='</div>';
15550        }}else{{reportCell='<span class="sx-e7c11689" >&#8212;</span>';}}
15551        if(d.submodule_links&&d.submodule_links.length){{
15552          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
15553          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
15554          reportCell+='</div></details>';
15555        }}
15556        return '<tr>'
15557          +'<td>'+tsHtml+'</td>'
15558          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
15559          +'<td>'+runIdHtml+'</td>'
15560          +'<td>'+commitHtml+'</td>'
15561          +'<td>'+branchHtml+'</td>'
15562          +'<td>'+tags+'</td>'
15563          +'<td class="num">'+metricHtml+'</td>'
15564          +'<td class="report-cell">'+reportCell+'</td>'
15565          +'</tr>';
15566      }}).join('');
15567      var pgRange=document.getElementById('sh-pg-range');
15568      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
15569      var pgInfo=document.getElementById('sh-pg-info');
15570      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
15571      var pgBtns=document.getElementById('sh-pg-btns');
15572      if(pgBtns){{
15573        pgBtns.innerHTML='';
15574        function mkPgBtn(lbl,pg,active,disabled){{
15575          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
15576          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
15577          return b;
15578        }}
15579        pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
15580        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
15581        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
15582        pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
15583      }}
15584    }}
15585
15586    function wireTableBehavior(){{
15587      var pf=document.getElementById('sh-proj-filter');
15588      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
15589      var bf=document.getElementById('sh-branch-filter');
15590      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
15591      var rb=document.getElementById('sh-reset-btn');
15592      if(rb)rb.addEventListener('click',function(){{
15593        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
15594        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
15595        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
15596        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');}});
15597        renderShPage();
15598      }});
15599      var pps=document.getElementById('sh-per-page');
15600      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
15601      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
15602      ths.forEach(function(th){{
15603        th.addEventListener('click',function(e){{
15604          if(e.target.classList.contains('col-resize-handle'))return;
15605          var col=th.dataset.col;
15606          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
15607          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
15608          th.classList.add('sort-'+shSortOrder);
15609          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
15610          shPage=1;renderShPage();
15611        }});
15612      }});
15613      var table=document.getElementById('scan-history-table');
15614      if(!table)return;
15615      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
15616      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
15617      allThs.forEach(function(th,i){{
15618        var handle=th.querySelector('.col-resize-handle');
15619        if(!handle||!cols[i])return;
15620        var startX,startW;
15621        handle.addEventListener('mousedown',function(e){{
15622          e.stopPropagation();e.preventDefault();
15623          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
15624          handle.classList.add('dragging');
15625          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
15626          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
15627          document.addEventListener('mousemove',onMove);
15628          document.addEventListener('mouseup',onUp);
15629        }});
15630      }});
15631    }}
15632
15633    function renderTable(pts, yKey){{
15634      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
15635      var wrap=document.getElementById('data-table-wrap');
15636      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
15637      var yLabel=Y_LABELS[yKey]||yKey||'';
15638      shData=pts.slice().reverse();
15639      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
15640      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
15641      var branches={{}};
15642      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
15643      var branchOpts='<option value="">All branches</option>';
15644      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
15645      wrap.innerHTML=
15646        '<div class="chart-section-header">SCAN HISTORY</div>'+
15647        '<div class="filter-row">'+
15648          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
15649          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
15650          '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
15651        '</div>'+
15652        '<div class="table-wrap">'+
15653        '<table id="scan-history-table" class="data-table">'+
15654        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
15655        '<thead><tr id="sh-thead">'+
15656        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
15657        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
15658        '<th>Run ID<div class="col-resize-handle"></div></th>'+
15659        '<th>Commit<div class="col-resize-handle"></div></th>'+
15660        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
15661        '<th>Tags<div class="col-resize-handle"></div></th>'+
15662        '<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>'+
15663        '<th>Report<div class="col-resize-handle"></div></th>'+
15664        '</tr></thead>'+
15665        '<tbody id="sh-tbody"></tbody>'+
15666        '</table>'+
15667        '</div>'+
15668        '<div class="pagination">'+
15669          '<span class="pagination-info" id="sh-pg-info"></span>'+
15670          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
15671          '<div class="sx-68443324" >'+
15672            '<span class="sx-68694475" >Show</span>'+
15673            '<select class="filter-select" id="sh-per-page">'+
15674              '<option value="10">10 per page</option>'+
15675              '<option value="25" selected>25 per page</option>'+
15676              '<option value="50">50 per page</option>'+
15677              '<option value="100">100 per page</option>'+
15678            '</select>'+
15679            '<span class="sx-68694475"  id="sh-pg-range"></span>'+
15680          '</div>'+
15681        '</div>';
15682      wireTableBehavior();
15683      renderShPage();
15684    }}
15685
15686    function exportXLSX(){{
15687      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
15688      var xbtn=document.getElementById('export-xlsx-btn');
15689      var xorig=xbtn?xbtn.innerHTML:'';
15690      if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
15691      var root=rootSel.value;
15692      var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
15693      fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
15694        var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
15695        buildAndDownloadXLSX(cm);
15696      }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
15697    }}
15698
15699    function buildAndDownloadXLSX(churnMap){{
15700      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
15701      // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
15702      // (sorted is newest-first), so a given project/commit appears at most once.
15703      var seenPC={{}},dedup=[];
15704      sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
15705      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
15706      var s1R=dedup.map(function(d){{
15707        var c=churnMap[d.run_id]||{{}};
15708        return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0,(+(c.added)||0)+(+(c.removed)||0)+(+(c.modified)||0)+(+(c.unmodified)||0)];
15709      }});
15710      var pm={{}};
15711      dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
15712      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'];
15713      var s2R=Object.keys(pm).map(function(p){{
15714        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
15715        var lat=sc[sc.length-1],fst=sc[0];
15716        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
15717        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);
15718        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];
15719      }});
15720      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
15721      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
15722      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15723      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15724    }}
15725
15726    function buildXLSX(sheets,chartRows,chartRows2){{
15727      function s2b(s){{return new TextEncoder().encode(s);}}
15728      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
15729      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;}}
15730      function crc32(d){{
15731        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;}}}}
15732        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15733      }}
15734      function buildSheet(hdr,rows,drawRid,withCtrl){{
15735        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15736        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
15737        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
15738        x+='<row r="1">';
15739        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
15740        if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
15741        x+='</row>';
15742        rows.forEach(function(row,ri){{
15743          var rn=ri+2;
15744          x+='<row r="'+rn+'">';
15745          row.forEach(function(cell,ci){{
15746            var addr=col2l(ci+1)+rn;
15747            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
15748            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
15749          }});
15750          if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\",\"Total\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+",P"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
15751          x+='</row>';
15752        }});
15753        x+='</sheetData>';
15754        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
15755        return x+'</worksheet>';
15756      }}
15757      function buildChartXML(rows){{
15758        var sn="'Scan History'";
15759        var nr=rows.length,er=nr+1;
15760        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'}}];
15761        var catCol='C',catIdx=2;
15762        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15763        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">';
15764        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
15765        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>';
15766        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
15767        sd.forEach(function(s,i){{
15768          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
15769          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>';
15770          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
15771          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>';
15772          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
15773          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
15774          x+='</c:strCache></c:strRef></c:cat>';
15775          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+'"/>';
15776          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
15777          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
15778        }});
15779        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
15780        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>';
15781        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>';
15782        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
15783        return x;
15784      }}
15785      function buildChartXML2(rows){{
15786        var sn="'By Project'";
15787        var nr=rows.length,er=nr+1;
15788        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'}}];
15789        var catCol='A',catIdx=0;
15790        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15791        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">';
15792        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
15793        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>';
15794        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
15795        sd.forEach(function(s,i){{
15796          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
15797          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>';
15798          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
15799          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>';
15800          x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
15801          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
15802          x+='</c:strCache></c:strRef></c:cat>';
15803          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+'"/>';
15804          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
15805          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
15806        }});
15807        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
15808        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>';
15809        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>';
15810        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
15811        return x;
15812      }}
15813      function buildChartXML3(rows){{
15814        var sn="'Scan History'";
15815        var nr=rows.length,er=nr+1;
15816        var catCol='C',catIdx=2;
15817        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15818        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">';
15819        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
15820        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
15821        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
15822        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>";
15823        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
15824        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>';
15825        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>';
15826        x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
15827        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
15828        x+='</c:strCache></c:strRef></c:cat>';
15829        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+'"/>';
15830        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
15831        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
15832        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
15833        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>';
15834        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>';
15835        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>';
15836        return x;
15837      }}
15838      function buildFocusSheet(drawRid){{
15839        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15840        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
15841        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
15842        x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
15843        x+='<sheetData><row r="1">';
15844        x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
15845        x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
15846        x+='<c r="D1" t="inlineStr"><is><t>&#8592; Pick a metric from the dropdown to update the chart below</t></is></c>';
15847        x+='</row></sheetData>';
15848        x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified,Total"</formula1></dataValidation></dataValidations>';
15849        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
15850        return x+'</worksheet>';
15851      }}
15852      var hasChart=!!(chartRows&&chartRows.length);
15853      var nr=hasChart?chartRows.length:0;
15854      var hasChart2=!!(chartRows2&&chartRows2.length);
15855      var nr2=hasChart2?chartRows2.length:0;
15856      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>';
15857      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"/>';
15858      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
15859      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"/>';}}
15860      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"/>';}}
15861      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15862      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>';
15863      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
15864      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"/>';}});
15865      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
15866      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>';
15867      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
15868      wbx+='</sheets></workbook>';
15869      var files=[
15870        {{name:'[Content_Types].xml',data:s2b(ct)}},
15871        {{name:'_rels/.rels',data:s2b(dotrels)}},
15872        {{name:'xl/workbook.xml',data:s2b(wbx)}},
15873        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15874        {{name:'xl/styles.xml',data:s2b(styl)}}
15875      ];
15876      // Chart embedded directly in Scan History (sheet1); By Project is plain
15877      sheets.forEach(function(s,i){{
15878        var sx;
15879        if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
15880        else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
15881        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
15882      }});
15883      if(hasChart){{
15884        var fromRow=nr+4,toRow=nr+34;
15885        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>')}});
15886        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15887        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">';
15888        drx+='<xdr:twoCellAnchor editAs="twoCell">';
15889        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>';
15890        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>';
15891        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
15892        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
15893        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
15894        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
15895        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
15896        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
15897        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>')}});
15898        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
15899        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>')}});
15900        var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15901        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">';
15902        drx3+='<xdr:twoCellAnchor editAs="twoCell">';
15903        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>';
15904        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>';
15905        drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
15906        drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
15907        drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
15908        drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
15909        drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
15910        files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
15911        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>')}});
15912        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
15913      }}
15914      if(hasChart2){{
15915        var fromRow2=nr2+4,toRow2=nr2+36;
15916        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>')}});
15917        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15918        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">';
15919        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
15920        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>';
15921        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>';
15922        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
15923        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
15924        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
15925        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
15926        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
15927        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
15928        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>')}});
15929        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
15930      }}
15931      var parts=[],offsets=[],total=0;
15932      files.forEach(function(f){{
15933        offsets.push(total);
15934        var nb=s2b(f.name),crc=crc32(f.data);
15935        var h=new DataView(new ArrayBuffer(30+nb.length));
15936        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
15937        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
15938        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
15939        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
15940        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
15941        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
15942        total+=30+nb.length+f.data.length;
15943      }});
15944      var cdStart=total;
15945      files.forEach(function(f,fi){{
15946        var nb=s2b(f.name),crc=crc32(f.data);
15947        var cd=new DataView(new ArrayBuffer(46+nb.length));
15948        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
15949        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
15950        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
15951        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
15952        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
15953        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
15954        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
15955      }});
15956      var cdSz=total-cdStart;
15957      var eocd=new DataView(new ArrayBuffer(22));
15958      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
15959      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
15960      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
15961      parts.push(new Uint8Array(eocd.buffer));
15962      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
15963      var out=new Uint8Array(sz);var off=0;
15964      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15965      return out.buffer;
15966    }}
15967
15968    function trendTitleParts(){{
15969      var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
15970      var subSelEl=document.getElementById('sub-sel');
15971      var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
15972      var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
15973      var proj=(document.getElementById('root-sel').value)||'All projects';
15974      var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
15975      var cnt=(allData&&allData.length)||0;
15976      var now=new Date();
15977      function p2(n){{return(n<10?'0':'')+n;}}
15978      var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
15979      return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+'  \u00b7  '+cnt+' scan'+(cnt===1?'':'s')+'  \u00b7  Generated '+dstr,date:dstr}};
15980    }}
15981
15982    function exportPNG(){{
15983      var svgEl=document.querySelector('#chart-wrap svg');
15984      if(!svgEl){{alert('No chart to export yet.');return;}}
15985      var svgStr=new XMLSerializer().serializeToString(svgEl);
15986      var vb=svgEl.viewBox.baseVal,scale=2;
15987      var headerH=84,footerH=36;
15988      var lw=(vb.width||900),lh=(vb.height||380);
15989      var w=lw*scale,h=(lh+headerH+footerH)*scale;
15990      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
15991      var url=URL.createObjectURL(blob);
15992      var img=new Image();
15993      var tp=trendTitleParts();
15994      img.onload=function(){{
15995        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
15996        var ctx=canvas.getContext('2d');
15997        var cs=getComputedStyle(document.body);
15998        var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
15999        var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
16000        var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
16001        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
16002        ctx.scale(scale,scale);
16003        ctx.textBaseline='alphabetic';ctx.textAlign='left';
16004        ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
16005        ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
16006        ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
16007        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;
16008        ctx.drawImage(img,0,headerH);
16009        var fy=headerH+lh;
16010        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;
16011        ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
16012        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);
16013        ctx.textAlign='left';
16014        URL.revokeObjectURL(url);
16015        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
16016      }};
16017      img.src=url;
16018    }}
16019
16020    function exportPDF(){{
16021      var svgEl=document.querySelector('#chart-wrap svg');
16022      if(!svgEl){{alert('No chart to export yet.');return;}}
16023      var tp=trendTitleParts();
16024      var svgStr=new XMLSerializer().serializeToString(svgEl);
16025      var statsEl=document.getElementById('trend-stats');
16026      var statsHtml=statsEl?statsEl.innerHTML:'';
16027      var yK=document.getElementById('y-sel').value;
16028      var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
16029      var yL=yLabels[yK]||yK;
16030      var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
16031      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 class="sx-5f326564" >'+esc(yL)+'</th></tr></thead><tbody>';
16032      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 class="sx-5f326564" >'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
16033      tableHtml+='</tbody></table>';
16034      var css='<style>'
16035        +'*{{box-sizing:border-box;}}'
16036        +'html,body{{margin:0;padding:0;}}'
16037        // Masthead/footer flow in document order — a position:fixed header repeats
16038        // on every printed page in Chromium and hides the rows beneath it on pages
16039        // 2+. The trend table's <thead> repeats per page natively instead.
16040        +'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;}}'
16041        +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
16042        +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
16043        +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
16044        +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
16045        +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
16046        +'.rep-body{{padding:22px 34px 0;}}'
16047        +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
16048        +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
16049        +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
16050        +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
16051        +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
16052        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
16053        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
16054        +'.stat-chip-tip{{display:none!important;}}'
16055        +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
16056        +'.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;}}'
16057        +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
16058        +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
16059        +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
16060        +'.rep-chart svg{{max-width:100%;height:auto;}}'
16061        +'.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;}}'
16062        +'.filter-row{{display:none!important;}}'
16063        +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
16064        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
16065        +'th{{background:#f0e9e0;font-weight:800;}}'
16066        +'.sort-icon,.col-resize-handle{{display:none!important;}}'
16067        +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
16068        +'.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;}}'
16069        +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
16070        +'</style>';
16071      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
16072        +'<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>'
16073        +'<div class="rep-body">'
16074        +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
16075        +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
16076        +'<div class="summary-strip">'+statsHtml+'</div>'
16077        +'<div class="rep-chart">'+svgStr+'</div>'
16078        +tableHtml
16079        +'</div>'
16080        +'<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>'
16081        +'</body></html>';
16082      window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
16083    }}
16084
16085    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
16086      var el=document.getElementById(id);
16087      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
16088    }});
16089    // Reflow the width-filling SVG chart when the window resizes (debounced), so it
16090    // tracks the container like the responsive Chart.js charts do.
16091    var _rsT=null;
16092    window.addEventListener('resize',function(){{
16093      if(_rsT)clearTimeout(_rsT);
16094      _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
16095    }});
16096    rootSel.addEventListener('change',function(){{
16097      populateSubmodules(rootSel.value);
16098      loadAndRender();
16099    }});
16100    if(subSel)subSel.addEventListener('change',loadAndRender);
16101
16102    // ── Full View modal: re-render the trend chart larger using the same drawing code ──
16103    (function(){{
16104      var fvBtn=document.getElementById('tr-chart-fv-btn');
16105      if(!fvBtn)return;
16106      function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
16107      fvBtn.addEventListener('click',function(){{
16108        if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
16109        var yKey=document.getElementById('y-sel').value;
16110        var xMode=document.getElementById('x-sel').value;
16111        var pts=allData;
16112        if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
16113        pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
16114        if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
16115        var tp=trendTitleParts();
16116        var ov=document.createElement('div');
16117        ov.className='tr-chart-full-modal';
16118        ov.innerHTML='<div class="tr-chart-full-inner">'
16119          +'<button type="button" class="settings-close sx-6681bcb5"  aria-label="Close">'
16120          +'<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>'
16121          +'<div class="sx-d6594fa1" >'+esc(tp.title)+'</div>'
16122          +'<div class="sx-6193e2c8" >'+esc(tp.sub)+'</div>'
16123          +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
16124        document.body.appendChild(ov);
16125        var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
16126        renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
16127        ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
16128        ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
16129        document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
16130      }});
16131    }})();
16132
16133    var xlsxBtn=document.getElementById('export-xlsx-btn');
16134    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
16135    var pngBtn=document.getElementById('export-png-btn');
16136    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
16137    var pdfBtn=document.getElementById('export-pdf-btn');
16138    if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
16139
16140    // ── Clean-up modal ───────────────────────────────────────────────────────
16141    (function(){{
16142      var triggerBtn=document.getElementById('cleanup-runs-btn');
16143      if(!triggerBtn)return;
16144      var modal=document.createElement('div');
16145      modal.className='tr-modal-backdrop';
16146      modal.innerHTML='<div class="tr-modal sx-82c06388" >'
16147        +'<div class="tr-modal-head">'
16148        +'<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>'
16149        +'<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>'
16150        +'</div>'
16151        +'<div class="tr-modal-body">'
16152        +'<p class="sx-06c7d19c" >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>'
16153        +'<label class="sx-c1c2b975" >Delete runs older than</label>'
16154        +'<div class="sx-2412f486" >'
16155        +'<input class="sx-46963105" type="number" id="cleanup-days-input" value="30" min="1" max="3650" >'
16156        +'<span class="sx-68694475" >days</span></div>'
16157        +'<div class="sx-aa34377b" id="cleanup-status" ></div>'
16158        +'</div>'
16159        +'<div class="tr-modal-foot">'
16160        +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
16161        +'<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>'
16162        +'</div></div>';
16163      document.body.appendChild(modal);
16164      triggerBtn.addEventListener('click',function(){{
16165        document.getElementById('cleanup-status').style.display='none';
16166        modal.style.display='flex';
16167      }});
16168      document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
16169      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
16170      document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
16171        var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
16172        var confirmBtn=this;
16173        confirmBtn.disabled=true;
16174        var status=document.getElementById('cleanup-status');
16175        status.style.display='block';
16176        status.style.background='#dbeafe';status.style.color='#1e40af';
16177        status.textContent='Deleting\u2026';
16178        fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
16179        .then(function(resp){{
16180          return resp.json().then(function(d){{
16181            if(resp.ok){{
16182              status.style.background='#dcfce7';status.style.color='#166534';
16183              status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
16184              setTimeout(function(){{window.location.reload();}},1500);
16185            }}else{{
16186              status.style.background='#fee2e2';status.style.color='#991b1b';
16187              status.textContent='Error: '+(d.error||'Unexpected error');
16188              confirmBtn.disabled=false;
16189            }}
16190          }});
16191        }})
16192        .catch(function(e){{
16193          status.style.background='#fee2e2';status.style.color='#991b1b';
16194          status.textContent='Network error: '+String(e);
16195          confirmBtn.disabled=false;
16196        }});
16197      }});
16198    }})();
16199
16200    // ── Retention policy panel ────────────────────────────────────────────────
16201    (function(){{
16202      var triggerBtn=document.getElementById('retention-policy-btn');
16203      if(!triggerBtn)return;
16204      var modal=document.createElement('div');
16205      modal.className='tr-modal-backdrop';
16206      modal.style.zIndex='9001';
16207      modal.innerHTML=''
16208        +'<div class="tr-modal sx-6bb239c6" >'
16209        +'<div class="tr-modal-head">'
16210        +'<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>'
16211        +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
16212        +'</div>'
16213        +'<div class="tr-modal-body">'
16214        +'<p class="sx-e0c3d11f" >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>'
16215        +'<div class="sx-62b3da63" >'
16216        +'<input class="sx-d08cd3b7" type="checkbox" id="rp-enabled" >'
16217        +'<label class="sx-4c8da600" for="rp-enabled" >Enable auto-cleanup</label>'
16218        +'</div>'
16219        +'<div class="sx-1641a130" >'
16220        +'<div>'
16221        +'<label class="sx-64dbe608" >Max age (days)</label>'
16222        +'<input class="sx-18abc423" type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" >'
16223        +'<div class="sx-1214055e" >Delete runs older than N days</div>'
16224        +'</div>'
16225        +'<div>'
16226        +'<label class="sx-64dbe608" >Max runs kept</label>'
16227        +'<input class="sx-18abc423" type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" >'
16228        +'<div class="sx-1214055e" >Keep only the N most recent runs</div>'
16229        +'</div>'
16230        +'<div>'
16231        +'<label class="sx-64dbe608" >Max total size (MB)</label>'
16232        +'<input class="sx-18abc423" type="number" id="rp-max-total" min="1" max="10000000" placeholder="No limit" >'
16233        +'<div class="sx-1214055e" >Delete oldest runs when the artifact tree exceeds this. Capped by the host SLOC_MAX_DISK_MB ceiling.</div>'
16234        +'</div>'
16235        +'</div>'
16236        +'<div class="sx-3c4a3d44" >'
16237        +'<label class="sx-64dbe608" >Check interval</label>'
16238        +'<select class="sx-163c74e0" id="rp-interval" >'
16239        +'<option value="1">Every hour</option>'
16240        +'<option value="6">Every 6 hours</option>'
16241        +'<option value="12">Every 12 hours</option>'
16242        +'<option value="24" selected>Every 24 hours</option>'
16243        +'<option value="48">Every 2 days</option>'
16244        +'<option value="72">Every 3 days</option>'
16245        +'<option value="168">Every week</option>'
16246        +'</select>'
16247        +'</div>'
16248        +'<div class="sx-6e3619f2" id="rp-last-run" >\u2014</div>'
16249        +'<div class="sx-ef999f9b" id="rp-status" ></div>'
16250        +'</div>'
16251        +'<div class="tr-modal-foot">'
16252        +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
16253        +'<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>'
16254        +'<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>'
16255        +'</div>'
16256        +'</div>';
16257      document.body.appendChild(modal);
16258
16259      function rpShowStatus(msg,ok){{
16260        var s=document.getElementById('rp-status');
16261        s.style.display='block';
16262        s.style.background=ok?'#dcfce7':'#fee2e2';
16263        s.style.color=ok?'#166534':'#991b1b';
16264        s.textContent=msg;
16265      }}
16266      function fmtAgo(iso){{
16267        if(!iso)return'Never';
16268        var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
16269        if(diff<60)return diff+'s ago';
16270        if(diff<3600)return Math.floor(diff/60)+'m ago';
16271        if(diff<86400)return Math.floor(diff/3600)+'h ago';
16272        return Math.floor(diff/86400)+'d ago';
16273      }}
16274      function loadPolicy(){{
16275        fetch('/api/cleanup-policy')
16276          .then(function(r){{return r.json();}})
16277          .then(function(d){{
16278            var p=d.policy;
16279            document.getElementById('rp-enabled').checked=p?p.enabled:false;
16280            document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
16281            document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
16282            document.getElementById('rp-max-total').value=(p&&p.max_total_mb!=null)?p.max_total_mb:'';
16283            var sel=document.getElementById('rp-interval');
16284            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;}}}}}}
16285            var lr=document.getElementById('rp-last-run');
16286            if(d.last_run_at){{
16287              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'):'');
16288            }}else{{
16289              lr.textContent='Auto-cleanup has not run yet.';
16290            }}
16291          }})
16292          .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
16293      }}
16294
16295      triggerBtn.addEventListener('click',function(){{
16296        document.getElementById('rp-status').style.display='none';
16297        loadPolicy();
16298        modal.style.display='flex';
16299      }});
16300      document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
16301      modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
16302
16303      document.getElementById('rp-save-btn').addEventListener('click',function(){{
16304        var enabled=document.getElementById('rp-enabled').checked;
16305        var ageVal=document.getElementById('rp-max-age').value.trim();
16306        var countVal=document.getElementById('rp-max-count').value.trim();
16307        var totalVal=document.getElementById('rp-max-total').value.trim();
16308        var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
16309        if(enabled&&!ageVal&&!countVal&&!totalVal){{
16310          rpShowStatus('Set at least one rule (max age, max count, or max total size) before enabling.',false);
16311          return;
16312        }}
16313        var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,max_total_mb:totalVal?parseInt(totalVal,10):null,interval_hours:intervalHours}};
16314        var saveBtn=document.getElementById('rp-save-btn');
16315        saveBtn.disabled=true;
16316        fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
16317          .then(function(r){{
16318            if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
16319            else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
16320          }})
16321          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
16322          .finally(function(){{saveBtn.disabled=false;}});
16323      }});
16324
16325      document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
16326        var btn=this;
16327        var orig=btn.innerHTML;
16328        btn.disabled=true;
16329        btn.textContent='Running\u2026';
16330        fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
16331          .then(function(r){{return r.json();}})
16332          .then(function(d){{
16333            rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
16334            loadPolicy();
16335          }})
16336          .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
16337          .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
16338      }});
16339    }})();
16340
16341    populateSubmodules(rootSel.value);
16342    loadAndRender();
16343
16344    (function randomizeWatermarks() {{
16345      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16346      if (!wms.length) return;
16347      var placed = [];
16348      function tooClose(top, left) {{
16349        for (var i = 0; i < placed.length; i++) {{
16350          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
16351          if (dt < 16 && dl < 12) return true;
16352        }}
16353        return false;
16354      }}
16355      function pick(leftBand) {{
16356        for (var attempt = 0; attempt < 50; attempt++) {{
16357          var top = Math.random() * 88 + 2;
16358          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16359          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
16360        }}
16361        var top = Math.random() * 88 + 2;
16362        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16363        placed.push([top, left]); return [top, left];
16364      }}
16365      var half = Math.floor(wms.length / 2);
16366      wms.forEach(function (img, i) {{
16367        var pos = pick(i < half);
16368        var size = Math.floor(Math.random() * 100 + 120);
16369        var rot = (Math.random() * 360).toFixed(1);
16370        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
16371        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;
16372      }});
16373    }})();
16374    (function spawnCodeParticles() {{
16375      var container = document.getElementById('code-particles');
16376      if (!container) return;
16377      var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
16378      var count = 44;
16379      for (var i = 0; i < count; i++) {{
16380        (function(idx) {{
16381          var el = document.createElement('span');
16382          el.className = 'code-particle';
16383          el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
16384          var left = Math.random() * 94 + 2;
16385          var top = Math.random() * 88 + 6;
16386          var dur = (Math.random() * 10 + 9).toFixed(1);
16387          var delay = (Math.random() * 18).toFixed(1);
16388          var rot = (Math.random() * 26 - 13).toFixed(1);
16389          var op = (Math.random() * 0.108 + 0.072).toFixed(3);
16390          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';
16391          container.appendChild(el);
16392        }})(i);
16393      }}
16394    }})();
16395  </script>
16396  <footer class="site-footer">
16397    local code analysis - metrics, history and reports
16398    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{version} — Mode: Local</em>
16399    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16400    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16401    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16402    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16403  </footer>
16404  <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>
16405  {toast_assets}
16406</body>
16407</html>"##,
16408    );
16409
16410    Html(html).into_response()
16411}
16412
16413fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
16414    use std::collections::HashMap;
16415    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
16416        return vec![];
16417    }
16418    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
16419    for rec in per_file_records {
16420        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
16421            let e = totals.entry(lang.display_name().to_string()).or_default();
16422            e.0 += u64::from(cov.lines_found);
16423            e.1 += u64::from(cov.lines_hit);
16424        }
16425    }
16426    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
16427    let mut pairs: Vec<(String, f64)> = totals
16428        .into_iter()
16429        .filter(|(_, (found, _))| *found > 0)
16430        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
16431        .collect();
16432    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
16433    pairs
16434        .iter()
16435        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
16436        .collect()
16437}
16438
16439fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
16440    let mut high = 0u64;
16441    let mut mid = 0u64;
16442    let mut low = 0u64;
16443    for rec in per_file_records {
16444        if let Some(cov) = &rec.coverage {
16445            if cov.lines_found == 0 {
16446                continue;
16447            }
16448            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
16449            if pct >= 80.0 {
16450                high += 1;
16451            } else if pct >= 50.0 {
16452                mid += 1;
16453            } else {
16454                low += 1;
16455            }
16456        }
16457    }
16458    (high, mid, low)
16459}
16460
16461fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
16462    let mut arr: Vec<serde_json::Value> = per_file_records
16463        .iter()
16464        .filter_map(|rec| {
16465            rec.coverage.as_ref().map(|cov| {
16466                let line_pct = if cov.lines_found > 0 {
16467                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
16468                        / 10.0
16469                } else {
16470                    0.0
16471                };
16472                let fn_pct = if cov.functions_found > 0 {
16473                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
16474                        .round()
16475                        / 10.0
16476                } else {
16477                    -1.0
16478                };
16479                serde_json::json!({
16480                    "rel": rec.relative_path,
16481                    "lang": rec.language.map_or("?", |l| l.display_name()),
16482                    "line_pct": line_pct,
16483                    "fn_pct": fn_pct,
16484                    "lhit": cov.lines_hit,
16485                    "lfound": cov.lines_found,
16486                    "fhit": cov.functions_hit,
16487                    "ffound": cov.functions_found,
16488                })
16489            })
16490        })
16491        .collect();
16492    arr.sort_by(|a, b| {
16493        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
16494        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
16495        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
16496    });
16497    arr
16498}
16499
16500#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
16501fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
16502    let mut langs: Vec<&sloc_core::LanguageSummary> = run
16503        .totals_by_language
16504        .iter()
16505        .filter(|l| l.test_count > 0)
16506        .collect();
16507    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
16508    let lang_tests: Vec<serde_json::Value> = langs
16509        .iter()
16510        .map(|l| {
16511            let d = if l.code_lines > 0 {
16512                l.test_count as f64 / l.code_lines as f64 * 1000.0
16513            } else {
16514                0.0
16515            };
16516            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
16517                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
16518                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
16519        })
16520        .collect();
16521    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
16522    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
16523    let t = &run.summary_totals;
16524    let total_tests = t.test_count;
16525    let density = if t.code_lines > 0 {
16526        total_tests as f64 / t.code_lines as f64 * 1000.0
16527    } else {
16528        0.0
16529    };
16530    let most_tested = langs.first().map_or_else(
16531        || "\u{2014}".to_string(),
16532        |l| l.language.display_name().to_string(),
16533    );
16534    let test_files: u64 = run
16535        .per_file_records
16536        .iter()
16537        .filter(|f| f.raw_line_categories.test_count > 0)
16538        .count() as u64;
16539    let cov_line = if t.coverage_lines_found > 0 {
16540        format!(
16541            "{:.1}",
16542            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
16543        )
16544    } else {
16545        "0".to_string()
16546    };
16547    let cov_fn = if t.coverage_functions_found > 0 {
16548        format!(
16549            "{:.1}",
16550            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
16551        )
16552    } else {
16553        "0".to_string()
16554    };
16555    let cov_branch = if t.coverage_branches_found > 0 {
16556        format!(
16557            "{:.1}",
16558            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
16559        )
16560    } else {
16561        "0".to_string()
16562    };
16563    let has_cov = !cov_arr.is_empty();
16564    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
16565    serde_json::json!({
16566        "totals": {
16567            "test_count": total_tests,
16568            "assertions": t.test_assertion_count,
16569            "suites": t.test_suite_count,
16570            "test_files": test_files,
16571            "total_files": t.files_analyzed,
16572            "density_str": format!("{density:.1}"),
16573            "most_tested": most_tested,
16574            "langs_with_tests": langs.len(),
16575            "cov_line": cov_line,
16576            "cov_fn": cov_fn,
16577            "cov_branch": cov_branch,
16578        },
16579        "lang_tests": lang_tests,
16580        "cov": cov_arr,
16581        "cov_tiers": {"high": high, "mid": mid, "low": low},
16582        "file_cov": file_cov_arr,
16583        "has_coverage": has_cov,
16584        "submodules": {},
16585    })
16586}
16587
16588#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
16589fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
16590    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
16591        .language_summaries
16592        .iter()
16593        .filter(|l| l.test_count > 0)
16594        .collect();
16595    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
16596    let lang_tests: Vec<serde_json::Value> = langs
16597        .iter()
16598        .map(|l| {
16599            let d = if l.code_lines > 0 {
16600                l.test_count as f64 / l.code_lines as f64 * 1000.0
16601            } else {
16602                0.0
16603            };
16604            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
16605                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
16606                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
16607        })
16608        .collect();
16609    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
16610    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
16611    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
16612    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
16613    let density = if sub.code_lines > 0 {
16614        total_tests as f64 / sub.code_lines as f64 * 1000.0
16615    } else {
16616        0.0
16617    };
16618    let most_tested = langs.first().map_or_else(
16619        || "\u{2014}".to_string(),
16620        |l| l.language.display_name().to_string(),
16621    );
16622    serde_json::json!({
16623        "totals": {
16624            "test_count": total_tests,
16625            "assertions": total_assertions,
16626            "suites": total_suites,
16627            "test_files": test_files_approx,
16628            "total_files": sub.files_analyzed,
16629            "density_str": format!("{density:.1}"),
16630            "most_tested": most_tested,
16631            "langs_with_tests": langs.len(),
16632            "cov_line": "0",
16633            "cov_fn": "0",
16634            "cov_branch": "0",
16635        },
16636        "lang_tests": lang_tests,
16637        "cov": [],
16638        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
16639        "has_coverage": false,
16640    })
16641}
16642
16643fn compute_cov_json_str(run: &AnalysisRun) -> String {
16644    use std::collections::HashMap;
16645    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
16646    for rec in &run.per_file_records {
16647        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
16648            let e = totals.entry(lang.display_name().to_string()).or_default();
16649            e.0 += u64::from(cov.lines_found);
16650            e.1 += u64::from(cov.lines_hit);
16651        }
16652    }
16653    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
16654    let mut pairs: Vec<(String, f64)> = totals
16655        .into_iter()
16656        .filter(|(_, (found, _))| *found > 0)
16657        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
16658        .collect();
16659    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
16660    let parts: Vec<String> = pairs
16661        .iter()
16662        .map(|(lang, pct)| {
16663            let name = lang.replace('"', "\\\"");
16664            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
16665        })
16666        .collect();
16667    format!("[{}]", parts.join(","))
16668}
16669
16670fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
16671    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
16672    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
16673}
16674
16675fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
16676    let mut entry = build_test_scope_entry(run);
16677    if !run.submodule_summaries.is_empty() {
16678        let subs: serde_json::Map<String, serde_json::Value> = run
16679            .submodule_summaries
16680            .iter()
16681            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
16682            .collect();
16683        entry["submodules"] = serde_json::Value::Object(subs);
16684    }
16685    entry
16686}
16687
16688fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
16689    let name = l.language.display_name().replace('"', "\\\"");
16690    #[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
16691    let density = if l.code_lines > 0 {
16692        l.test_count as f64 / l.code_lines as f64 * 1000.0
16693    } else {
16694        0.0
16695    };
16696    format!(
16697        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
16698        name = name,
16699        t = l.test_count,
16700        a = l.test_assertion_count,
16701        s = l.test_suite_count,
16702        c = l.code_lines,
16703        d = density,
16704        f = l.files,
16705    )
16706}
16707
16708fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
16709    let Some(r) = run else {
16710        return "[]".to_string();
16711    };
16712    let mut langs: Vec<&sloc_core::LanguageSummary> = r
16713        .totals_by_language
16714        .iter()
16715        .filter(|l| l.test_count > 0)
16716        .collect();
16717    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
16718    let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
16719    format!("[{}]", parts.join(","))
16720}
16721
16722/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
16723async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
16724    let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
16725    scope_map.insert(
16726        "__all__".to_string(),
16727        latest_run.map_or_else(
16728            || {
16729                serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
16730                    "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
16731                    "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
16732                    "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
16733                    "has_coverage":false,"submodules":{}})
16734            },
16735            build_test_scope_entry,
16736        ),
16737    );
16738    let all_roots: Vec<String> = {
16739        let reg = state.registry.lock().await;
16740        let mut seen = std::collections::BTreeSet::new();
16741        reg.entries
16742            .iter()
16743            .flat_map(|e| e.input_roots.iter().cloned())
16744            .filter(|r| seen.insert(r.clone()))
16745            .collect()
16746    };
16747    for root in &all_roots {
16748        let json_path = {
16749            let reg = state.registry.lock().await;
16750            reg.entries
16751                .iter()
16752                .find(|e| e.input_roots.iter().any(|r| r == root))
16753                .and_then(|e| e.json_path.clone())
16754        };
16755        let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
16756            let json_str = tokio::fs::read_to_string(&p).await.ok();
16757            json_str
16758                .as_deref()
16759                .and_then(|s| serde_json::from_str(s).ok())
16760        } else {
16761            None
16762        };
16763        if let Some(ref run) = run_for_root {
16764            scope_map.insert(root.clone(), build_scope_entry_for_run(run));
16765        }
16766    }
16767    serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
16768}
16769
16770// GET /test-metrics
16771#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
16772#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
16773async fn test_metrics_handler(
16774    State(state): State<AppState>,
16775    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
16776) -> Response {
16777    auto_scan_watched_dirs(&state).await;
16778    let watched_dirs_list: Vec<String> = {
16779        let wd = state.watched_dirs.lock().await;
16780        wd.dirs.iter().map(|p| p.display().to_string()).collect()
16781    };
16782    let latest_run: Option<AnalysisRun> = {
16783        let json_path = {
16784            let reg = state.registry.lock().await;
16785            reg.entries.first().and_then(|e| e.json_path.clone())
16786        };
16787        if let Some(p) = json_path {
16788            let json_str = tokio::fs::read_to_string(&p).await.ok();
16789            json_str
16790                .as_deref()
16791                .and_then(|s| serde_json::from_str(s).ok())
16792        } else {
16793            None
16794        }
16795    };
16796
16797    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
16798    let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
16799
16800    // Build coverage chart JSON (per-language avg line coverage %).
16801    let cov_json: String = latest_run
16802        .as_ref()
16803        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
16804        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
16805
16806    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
16807    let _cov_tier_json: String = latest_run
16808        .as_ref()
16809        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
16810        .map_or_else(
16811            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
16812            compute_cov_tier_json_str,
16813        );
16814
16815    let total_tests: u64 = latest_run
16816        .as_ref()
16817        .map_or(0, |r| r.summary_totals.test_count);
16818    let total_assertions: u64 = latest_run
16819        .as_ref()
16820        .map_or(0, |r| r.summary_totals.test_assertion_count);
16821    let total_suites: u64 = latest_run
16822        .as_ref()
16823        .map_or(0, |r| r.summary_totals.test_suite_count);
16824    let total_code: u64 = latest_run
16825        .as_ref()
16826        .map_or(0, |r| r.summary_totals.code_lines);
16827    let workspace_density: f64 = if total_code > 0 {
16828        total_tests as f64 / total_code as f64 * 1000.0
16829    } else {
16830        0.0
16831    };
16832    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
16833        r.totals_by_language
16834            .iter()
16835            .filter(|l| l.test_count > 0)
16836            .count()
16837    });
16838    let most_tested: String = latest_run
16839        .as_ref()
16840        .and_then(|r| {
16841            r.totals_by_language
16842                .iter()
16843                .filter(|l| l.test_count > 0)
16844                .max_by_key(|l| l.test_count)
16845        })
16846        .map_or_else(
16847            || "\u{2014}".to_string(),
16848            |l| l.language.display_name().to_string(),
16849        );
16850    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
16851        r.per_file_records
16852            .iter()
16853            .filter(|f| f.raw_line_categories.test_count > 0)
16854            .count() as u64
16855    });
16856    let total_files_analyzed: u64 = latest_run
16857        .as_ref()
16858        .map_or(0, |r| r.summary_totals.files_analyzed);
16859    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
16860
16861    // Aggregated coverage percentages from summary_totals
16862    let cov_line_pct_str: String = latest_run
16863        .as_ref()
16864        .filter(|r| r.summary_totals.coverage_lines_found > 0)
16865        .map_or_else(
16866            || "0".to_string(),
16867            |r| {
16868                format!(
16869                    "{:.1}",
16870                    r.summary_totals.coverage_lines_hit as f64
16871                        / r.summary_totals.coverage_lines_found as f64
16872                        * 100.0
16873                )
16874            },
16875        );
16876    let cov_fn_pct_str: String = latest_run
16877        .as_ref()
16878        .filter(|r| r.summary_totals.coverage_functions_found > 0)
16879        .map_or_else(
16880            || "0".to_string(),
16881            |r| {
16882                format!(
16883                    "{:.1}",
16884                    r.summary_totals.coverage_functions_hit as f64
16885                        / r.summary_totals.coverage_functions_found as f64
16886                        * 100.0
16887                )
16888            },
16889        );
16890    let cov_branch_pct_str: String = latest_run
16891        .as_ref()
16892        .filter(|r| r.summary_totals.coverage_branches_found > 0)
16893        .map_or_else(
16894            || "0".to_string(),
16895            |r| {
16896                format!(
16897                    "{:.1}",
16898                    r.summary_totals.coverage_branches_hit as f64
16899                        / r.summary_totals.coverage_branches_found as f64
16900                        * 100.0
16901                )
16902            },
16903        );
16904
16905    let cov_no_data_notice = if has_coverage {
16906        String::new()
16907    } else {
16908        String::from(
16909            r#"<div class="empty-state sx-c2289694" >
16910<div class="sx-1355ce7d" >No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
16911<div class="sx-823a8ee4" >
16912  <span class="sx-4041551f" >Supported formats</span>
16913  <span class="sx-4c7d880c" ><strong>LCOV</strong> <code>.info</code></span>
16914  <span class="sx-3dc23ded" >&middot;</span>
16915  <span class="sx-4c7d880c" ><strong>Cobertura XML</strong></span>
16916  <span class="sx-3dc23ded" >&middot;</span>
16917  <span class="sx-4c7d880c" ><strong>JaCoCo XML</strong></span>
16918  <span class="sx-3dc23ded" >&middot;</span>
16919  <span class="sx-4c7d880c" ><strong>coverage.py JSON</strong></span>
16920  <span class="sx-3dc23ded" >&middot;</span>
16921  <span class="sx-4c7d880c" ><strong>Istanbul JSON</strong></span>
16922</div>
16923<div class="sx-9d20ccc1" >Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
16924</div>"#,
16925        )
16926    };
16927
16928    let workspace_density_str = format!("{workspace_density:.1}");
16929    let nonce = &csp_nonce;
16930    let toast_assets = sloc_toast_assets(nonce);
16931    let version = env!("CARGO_PKG_VERSION");
16932
16933    // Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
16934    // of interactive controls — folder watching is managed by the host administrator.
16935    let watched_dirs_html: String = if state.server_mode {
16936        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()
16937    } else {
16938        let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
16939            r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
16940                .to_string()
16941        } else {
16942            watched_dirs_list
16943                .iter()
16944                .fold(String::new(), |mut s, d| {
16945                    use std::fmt::Write as _;
16946                    let escaped =
16947                        d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
16948                    write!(
16949                        s,
16950                        r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form class="sx-043808a9" method="POST" action="/watched-dirs/remove" ><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>"#
16951                    ).expect("write to String is infallible");
16952                    s
16953                })
16954        };
16955        format!(
16956            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 class="sx-043808a9" method="POST" action="/watched-dirs/refresh" ><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
16957        )
16958    };
16959
16960    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
16961    let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
16962
16963    let html = format!(
16964        r#"<!doctype html>
16965<html lang="en">
16966<head>
16967  <meta charset="utf-8" />
16968  <meta name="viewport" content="width=device-width, initial-scale=1" />
16969  <title>OxideSLOC | Test Metrics</title>
16970  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16971  <link rel="stylesheet" href="/static/app.css">
16972  <script src="/static/app.js"></script>
16973  <style nonce="{nonce}">
16974    :root {{
16975      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16976      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16977      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16978      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16979      --info-bg:#eef3ff; --info-text:#4467d8;
16980    }}
16981    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
16982    *{{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;}}
16983    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
16984    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
16985    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
16986    @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));}}}}
16987    .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);}}
16988    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
16989    .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));}}
16990    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
16991    .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;}}
16992    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
16993    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
16994    @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; }} }}
16995    .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;}}
16996    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
16997    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
16998    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
16999    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
17000    .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;}}
17001    .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;}}
17002    .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;}}
17003    .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;}}
17004    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
17005    .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);}}
17006    .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;}}
17007    .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;}}
17008    .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;}}
17009    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
17010    .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;}}
17011    .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);}}
17012    .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;}}
17013    .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;}}
17014    .tz-select:focus{{border-color:var(--oxide);}}
17015    .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
17016    @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
17017    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
17018    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
17019    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
17020    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
17021    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
17022    .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);}}
17023    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
17024    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
17025    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
17026    .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;}}
17027    .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;}}
17028    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
17029    .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
17030    .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);}}
17031    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
17032    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
17033    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
17034    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
17035    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
17036    .chart-canvas-wrap{{position:relative;height:280px;}}
17037    .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;}}
17038    .chart-no-data svg{{opacity:0.35;}}
17039    .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
17040    .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
17041    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
17042    .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;}}
17043    .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;}}
17044    .data-table tr:last-child td{{border-bottom:none;}}
17045    .data-table tbody tr:hover td{{background:var(--surface-2);}}
17046    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
17047    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
17048    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
17049    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
17050    .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;}}
17051    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
17052    .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);}}
17053    .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
17054    .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
17055    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
17056    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
17057    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
17058    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
17059    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
17060    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
17061    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
17062    .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;}}
17063    .chart-select:focus{{border-color:var(--accent);}}
17064    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
17065    .trend-canvas-wrap{{position:relative;height:260px;}}
17066    .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;}}
17067    .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
17068    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
17069    .site-footer a{{color:var(--muted);}}
17070    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
17071    .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;}}
17072    .btn:hover{{background:var(--surface-2);}}
17073    .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;}}
17074    .export-btn:hover{{background:var(--line);}}
17075    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
17076    /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
17077    .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
17078    .scope-export{{margin-left:auto;}}
17079    body.pdf-mode .export-group{{display:none!important;}}
17080    @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
17081    .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;}}
17082    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
17083    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
17084    .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;}}
17085    .scope-sel:focus{{border-color:var(--accent);}}
17086    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
17087    .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;}}
17088    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
17089    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
17090    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
17091    .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;}}
17092    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
17093    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
17094    .watched-chip-rm:hover{{color:var(--oxide);}}
17095    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
17096    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
17097    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
17098    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
17099    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
17100    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
17101    .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;}}
17102    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
17103    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
17104    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
17105    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
17106    .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;}}
17107    .cov-file-search:focus{{border-color:var(--accent);}}
17108    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
17109    .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;}}
17110    body.dark-theme .cov-file-search{{background:var(--surface);}}
17111    .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
17112    .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;}}
17113    .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
17114    .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;}}
17115    .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);}}
17116    .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
17117    .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
17118    .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;}}
17119    .chart-modal-close:hover{{opacity:.7;}}
17120    body.dark-theme .chart-modal{{background:var(--surface);}}
17121  </style>
17122</head>
17123<body>
17124  <div class="background-watermarks" aria-hidden="true">
17125    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17126    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17127    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17128    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17129    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17130    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17131  </div>
17132  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17133  <div class="top-nav">
17134    <div class="top-nav-inner">
17135      <a class="brand" href="/">
17136        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17137        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
17138      </a>
17139      <div class="nav-right">
17140        <a class="nav-pill" href="/">Home</a>
17141        <div class="nav-dropdown">
17142          <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>
17143          <div class="nav-dropdown-menu">
17144            <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>
17145          </div>
17146        </div>
17147        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17148        <a class="nav-pill sx-8c38ef73" href="/test-metrics" >Test Metrics</a>
17149        <div class="nav-dropdown">
17150          <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>
17151          <div class="nav-dropdown-menu">
17152            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
17153            <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>
17154          </div>
17155        </div>
17156        <div class="server-status-wrap" id="server-status-wrap">
17157          <div class="nav-pill server-online-pill" id="server-status-pill">
17158            <span class="status-dot" id="status-dot"></span>
17159            <span id="server-status-label">Server</span>
17160            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
17161          </div>
17162          <div class="server-status-tip">
17163            OxideSLOC is running — accessible on your network.
17164            <span class="sx-238af6bc" id="server-tip-ping" ></span>
17165          </div>
17166        </div>
17167        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17168          <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>
17169        </button>
17170        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17171          <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>
17172          <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>
17173        </button>
17174      </div>
17175    </div>
17176  </div>
17177
17178  <div class="page">
17179    {watched_dirs_html}
17180    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
17181      <div class="scan-overlay-card">
17182        <div class="scan-spinner"></div>
17183        <div class="scan-overlay-text">Scanning folder…</div>
17184        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
17185      </div>
17186    </div>
17187    <style nonce="{nonce}">
17188    .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);}}
17189    .scan-overlay.active{{display:flex;}}
17190    .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;}}
17191    .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;}}
17192    @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
17193    .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
17194    .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
17195    </style>
17196    <div class="scope-bar">
17197      <svg class="sx-e1242e80" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
17198      <span class="scope-label">Scope</span>
17199      <div class="scope-sel-wrap">
17200        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
17201        <div class="sx-8c9231df" id="scope-sub-wrap" >
17202          <svg class="sx-88d1e2d6" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ><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>
17203          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
17204        </div>
17205      </div>
17206      <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
17207      <div class="export-group scope-export" id="tm-export-group">
17208        <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)">
17209          <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>
17210          Export Excel
17211        </button>
17212        <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
17213          <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>
17214          Export PNG
17215        </button>
17216        <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
17217          <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>
17218          Export PDF
17219        </button>
17220      </div>
17221    </div>
17222    <div class="summary-strip sx-17674eb3" >
17223      <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>
17224      <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>
17225      <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>
17226      <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>
17227    </div>
17228    <div class="summary-strip sx-17674eb3" >
17229      <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>
17230      <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>
17231      <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>
17232      <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>
17233    </div>
17234
17235    <div class="panel" id="viz-panel">
17236      <div class="section-header sx-ea7dba9f" >Visualizations</div>
17237
17238      <div class="chart-box sx-50b6af6d" >
17239        <div class="chart-box-header">
17240          <div class="chart-box-title sx-768bda7d" >Test Count Trend</div>
17241          <div class="sx-0d5ff492" >
17242            <button class="chart-expand-btn sx-d0466aa3" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" >&#8652; Multi-Timeline</button>
17243            <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
17244          </div>
17245        </div>
17246        <p class="sx-33c9de91" >Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
17247        <div class="trend-controls-bar">
17248          <label>Y Metric:
17249            <select class="chart-select" id="tm-trend-y">
17250              <option value="test_count" selected>Test Definitions</option>
17251              <option value="code_lines">Code Lines</option>
17252            </select>
17253          </label>
17254          <label>X Axis:
17255            <select class="chart-select" id="tm-trend-x">
17256              <option value="commit" selected>By Commit</option>
17257              <option value="time">By Time</option>
17258            </select>
17259          </label>
17260          <label class="sx-d0466aa3" id="tm-sub-label" >Submodule:
17261            <select class="chart-select" id="tm-trend-sub">
17262              <option value="">All (project total)</option>
17263            </select>
17264          </label>
17265          <label>Chart Size:
17266            <select class="chart-select" id="tm-trend-size">
17267              <option value="200">Compact</option>
17268              <option value="260" selected>Normal</option>
17269              <option value="360">Large</option>
17270            </select>
17271          </label>
17272        </div>
17273        <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
17274        <div id="trend-empty" class="empty-state sx-d0466aa3" >No historical test data found. Run more scans to see trends.</div>
17275      </div>
17276
17277      <div class="chart-row">
17278        <div class="chart-box">
17279          <div class="chart-box-header">
17280            <div class="chart-box-title sx-768bda7d" >Test Definitions by Language</div>
17281            <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
17282          </div>
17283          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
17284          <div id="no-data-tests" class="chart-no-data sx-d0466aa3" ><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>
17285        </div>
17286        <div class="chart-box">
17287          <div class="chart-box-header">
17288            <div class="chart-box-title sx-768bda7d" >Test Density (per 1,000 code lines)</div>
17289            <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
17290          </div>
17291          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
17292          <div id="no-data-density" class="chart-no-data sx-d0466aa3" ><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>
17293        </div>
17294      </div>
17295
17296      <div class="chart-row">
17297        <div class="chart-box">
17298          <div class="chart-box-header">
17299            <div class="chart-box-title sx-768bda7d" >Assertions by Language</div>
17300            <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
17301          </div>
17302          <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
17303          <div id="no-data-assertions" class="chart-no-data sx-d0466aa3" ><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>
17304        </div>
17305        <div class="chart-box" id="suites-chart-box">
17306          <div class="chart-box-header">
17307            <div class="chart-box-title sx-768bda7d" >Test Suites by Language</div>
17308            <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
17309          </div>
17310          <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
17311          <div id="no-data-suites" class="chart-no-data sx-d0466aa3" ><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>
17312        </div>
17313      </div>
17314
17315      <div class="chart-row">
17316        <div class="chart-box">
17317          <div class="chart-box-title">Test Files Breakdown</div>
17318          <div class="chart-canvas-wrap sx-bbc01430" ><canvas id="canvas-files"></canvas></div>
17319          <div id="no-data-files" class="chart-no-data sx-d0466aa3" ><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>
17320        </div>
17321        <div class="chart-box">
17322          <div class="chart-box-title">Test Composition</div>
17323          <p class="sx-7f7492d2" >Total counts: test functions, assertions, and suites workspace-wide.</p>
17324          <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
17325          <div id="no-data-composition" class="chart-no-data sx-d0466aa3" ><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>
17326        </div>
17327      </div>
17328    </div>
17329
17330    <div class="panel">
17331      <h1>Test Metrics</h1>
17332      <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>
17333
17334      <div class="section-header">Language Breakdown</div>
17335      {cov_no_data_notice}
17336      <div class="sx-fbff25e9" >
17337        <table class="data-table" id="lang-table">
17338          <thead><tr>
17339            <th>Language</th>
17340            <th class="num">Test Fns</th>
17341            <th class="num">Assertions</th>
17342            <th class="num">Suites</th>
17343            <th class="num">Code Lines</th>
17344            <th class="num">Files</th>
17345            <th class="num">Density / 1K</th>
17346            <th>Relative Density</th>
17347          </tr></thead>
17348          <tbody id="lang-tbody"></tbody>
17349        </table>
17350      </div>
17351    </div>
17352
17353    <div class="panel sx-d0466aa3" id="cov-panel" >
17354      <div class="section-header sx-ea7dba9f" >LCOV Coverage Summary</div>
17355      <div class="cov-gauge-row" id="cov-gauges">
17356        <div class="cov-gauge-card">
17357          <div class="cov-gauge-label">Line Coverage</div>
17358          <div class="cov-gauge-val sx-d63f7cf0" id="cov-line-val" >{cov_line_pct_str}%</div>
17359          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" data-sx-style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
17360          <div class="cov-gauge-sub">Lines hit / instrumented</div>
17361          <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>
17362        </div>
17363        <div class="cov-gauge-card">
17364          <div class="cov-gauge-label">Function Coverage</div>
17365          <div class="cov-gauge-val sx-e1c6661f" id="cov-fn-val" >{cov_fn_pct_str}%</div>
17366          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" data-sx-style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
17367          <div class="cov-gauge-sub">Functions hit / found</div>
17368          <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>
17369        </div>
17370        <div class="cov-gauge-card">
17371          <div class="cov-gauge-label">Branch Coverage</div>
17372          <div class="cov-gauge-val sx-e35e33d5" id="cov-branch-val" >{cov_branch_pct_str}%</div>
17373          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" data-sx-style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
17374          <div class="cov-gauge-sub">Branches hit / found</div>
17375          <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>
17376        </div>
17377      </div>
17378      <div class="chart-row">
17379        <div class="chart-box">
17380          <div class="chart-box-title">Line Coverage % by Language</div>
17381          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
17382        </div>
17383        <div class="chart-box">
17384          <div class="chart-box-title">Coverage Tier Distribution</div>
17385          <div class="chart-canvas-wrap sx-5d020860" ><canvas id="canvas-cov-tiers"></canvas></div>
17386        </div>
17387      </div>
17388
17389      <div class="section-header sx-04bbec5e" >Coverage File Detail</div>
17390      <p class="muted sx-16dbf2a3" >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>
17391      <div class="cov-file-toolbar">
17392        <div class="cov-filter-tabs" id="cov-filter-tabs">
17393          <button class="cov-tab active" data-tier="all">All</button>
17394          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
17395          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
17396          <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
17397          <button class="cov-tab" data-tier="high">High (≥80%)</button>
17398        </div>
17399        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
17400      </div>
17401      <div class="sx-fbff25e9" >
17402        <table class="data-table" id="cov-file-table">
17403          <thead><tr>
17404            <th>File</th>
17405            <th>Lang</th>
17406            <th class="num">Line %</th>
17407            <th class="num">Lines Hit / Found</th>
17408            <th class="num">Fn %</th>
17409            <th class="num">Fns Hit / Found</th>
17410          </tr></thead>
17411          <tbody id="cov-file-tbody"></tbody>
17412        </table>
17413      </div>
17414      <div class="sx-e5565aef" id="cov-file-empty" >No files match the current filter.</div>
17415      <div class="sx-6d1fab35" id="cov-file-count" ></div>
17416    </div>
17417
17418  </div>
17419
17420  <footer class="site-footer">
17421    local code analysis - metrics, history and reports
17422    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{version} — Mode: Server</em>
17423    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17424    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17425    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17426    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17427  </footer>
17428
17429  <script nonce="{nonce}">
17430  (function() {{
17431    // Theme
17432    var b = document.body;
17433    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
17434    var tgl = document.getElementById('theme-toggle');
17435    if (tgl) tgl.addEventListener('click', function() {{
17436      var d = b.classList.toggle('dark-theme');
17437      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
17438    }});
17439
17440    // Watermarks
17441    (function() {{
17442      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17443      if (!wms.length) return;
17444      var placed = [];
17445      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;}}
17446      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];}}
17447      var half=Math.floor(wms.length/2);
17448      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;}});
17449    }})();
17450
17451    // Code particles
17452    (function() {{
17453      var container = document.getElementById('code-particles');
17454      if (!container) return;
17455      var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
17456      for (var i = 0; i < 42; i++) {{
17457        (function(idx) {{
17458          var el = document.createElement('span');
17459          el.className = 'code-particle';
17460          el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
17461          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
17462          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
17463          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.108 + 0.072).toFixed(3);
17464          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';
17465          container.appendChild(el);
17466        }})(i);
17467      }}
17468    }})();
17469
17470    // Settings modal
17471    (function() {{
17472      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'}}];
17473      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);}});}}
17474      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
17475      var btn=document.getElementById('settings-btn');if(!btn)return;
17476      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17477      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
17478      document.body.appendChild(m);
17479      var g=document.getElementById('scheme-grid');
17480      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);}});
17481      var cl=document.getElementById('settings-close');
17482      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');}});
17483      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
17484      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
17485    }})();
17486
17487    // Watched folder picker
17488    (function(){{
17489      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');}};
17490      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);
17491    }})();
17492    (function() {{
17493      var btn = document.getElementById('add-watched-btn');
17494      if (!btn) return;
17495      btn.addEventListener('click', function() {{
17496        fetch('/pick-directory?kind=reports')
17497          .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
17498          .then(function(data) {{
17499            if (!data.cancelled && data.selected_path) {{
17500              var form = document.createElement('form');
17501              form.method = 'POST';
17502              form.action = '/watched-dirs/add';
17503              var ri = document.createElement('input');
17504              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
17505              var fi = document.createElement('input');
17506              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
17507              form.appendChild(ri); form.appendChild(fi);
17508              document.body.appendChild(form);
17509              if (window.__scanOverlay) window.__scanOverlay();
17510              form.submit();
17511            }}
17512          }})
17513          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
17514      }});
17515    }})();
17516  }})();
17517  </script>
17518
17519  <script src="/static/chart.js" nonce="{nonce}"></script>
17520  <script nonce="{nonce}">
17521  (function() {{
17522    var SCOPE_DATA = {scope_data_json};
17523    var currentRoot = '__all__';
17524    var currentSub  = '';
17525    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
17526    var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
17527    var ALL_CHARTS = [];
17528    var currentLangTests = [];
17529    var currentTrendPts = [];
17530
17531    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();}}
17532    function fmtFull(n){{return Number(n).toLocaleString();}}
17533    function isDark(){{return document.body.classList.contains('dark-theme');}}
17534    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
17535    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
17536    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
17537
17538    function makeDlPlugin(fmtFn, anchor) {{
17539      return {{
17540        afterDatasetsDraw: function(chart) {{
17541          var ctx = chart.ctx;
17542          var tc = txtClr();
17543          chart.data.datasets.forEach(function(ds, di) {{
17544            var meta = chart.getDatasetMeta(di);
17545            meta.data.forEach(function(el, idx) {{
17546              var label = fmtFn(ds.data[idx], di, idx);
17547              if (label == null || label === '') return;
17548              ctx.save();
17549              ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
17550              ctx.fillStyle = tc;
17551              if (anchor === 'top') {{
17552                ctx.textAlign = 'center';
17553                ctx.textBaseline = 'bottom';
17554                ctx.fillText(String(label), el.x, el.y - 5);
17555              }} else {{
17556                ctx.textAlign = 'left';
17557                ctx.textBaseline = 'middle';
17558                ctx.fillText(String(label), el.x + 5, el.y);
17559              }}
17560              ctx.restore();
17561            }});
17562          }});
17563        }}
17564      }};
17565    }}
17566
17567    // Cursor: pointer over chart data, default over empty chart area.
17568    function chartCursor(e, els) {{
17569      var t = e.native && e.native.target;
17570      if (t) t.style.cursor = els.length ? 'pointer' : 'default';
17571    }}
17572    Chart.defaults.onHover = chartCursor; // applies to every chart on this page
17573
17574    // ── Global bar hover emphasis ──────────────────────────────────────────────
17575    // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
17576    // *other* bars does nothing when there is only one). Give every bar chart a
17577    // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
17578    // animates via the fast active transition. Applied globally through a plugin so
17579    // it covers all current and future bar charts on the page.
17580    function tmLighten(c, amt) {{
17581      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
17582        var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
17583        r = Math.round(r + (255 - r) * amt);
17584        g = Math.round(g + (255 - g) * amt);
17585        b = Math.round(b + (255 - b) * amt);
17586        return 'rgb(' + r + ',' + g + ',' + b + ')';
17587      }}
17588      return c;
17589    }}
17590    var tmBarHoverEmphasis = {{
17591      id: 'tmBarHoverEmphasis',
17592      beforeInit: function(chart) {{
17593        if (!chart.config || chart.config.type !== 'bar') return;
17594        (chart.data.datasets || []).forEach(function(ds) {{
17595          var bg = ds.backgroundColor;
17596          if (ds.hoverBackgroundColor == null) {{
17597            ds.hoverBackgroundColor = Array.isArray(bg)
17598              ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
17599              : tmLighten(bg, 0.24);
17600          }}
17601          if (ds.hoverBorderColor == null) {{
17602            ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
17603          }}
17604          if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
17605        }});
17606      }}
17607    }};
17608    Chart.register(tmBarHoverEmphasis);
17609    // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
17610    try {{
17611      Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
17612      Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
17613      Chart.defaults.transitions.active.animation.duration = 260;
17614    }} catch (e) {{}}
17615
17616    // Plugin: draws % labels inside each doughnut slice.
17617    var donutPctPlugin = {{
17618      afterDatasetsDraw: function(chart) {{
17619        var ctx = chart.ctx;
17620        chart.data.datasets.forEach(function(ds, di) {{
17621          var meta = chart.getDatasetMeta(di);
17622          if (meta.hidden) return;
17623          var total = 0;
17624          for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
17625          if (!total) return;
17626          meta.data.forEach(function(arc, i) {{
17627            if (arc.hidden) return;
17628            var val = ds.data[i] || 0;
17629            var pct = val / total * 100;
17630            if (pct < 3) return;
17631            var midAngle = (arc.startAngle + arc.endAngle) / 2;
17632            var midR = (arc.innerRadius + arc.outerRadius) / 2;
17633            var tx = arc.x + midR * Math.cos(midAngle);
17634            var ty = arc.y + midR * Math.sin(midAngle);
17635            ctx.save();
17636            ctx.textAlign = 'center';
17637            ctx.textBaseline = 'middle';
17638            ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
17639            ctx.shadowColor = 'rgba(0,0,0,0.45)';
17640            ctx.shadowBlur = 3;
17641            ctx.fillStyle = '#fff';
17642            ctx.fillText(pct.toFixed(0) + '%', tx, ty);
17643            ctx.restore();
17644          }});
17645        }});
17646      }}
17647    }};
17648
17649    function makeTmOverlay(title, subtitle, h) {{
17650      var overlay = document.createElement('div');
17651      overlay.className = 'chart-modal-overlay';
17652      var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
17653      var ch = Math.min(h || 560, maxH);
17654      var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
17655      overlay.innerHTML = '<div class="chart-modal sx-02e8e0b3" ><button class="chart-modal-close" aria-label="Close">&times;</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div data-sx-style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
17656      document.body.appendChild(overlay);
17657      overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
17658      overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
17659      return document.getElementById('tm-modal-canvas');
17660    }}
17661
17662    function getDataset() {{
17663      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
17664      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
17665      return r;
17666    }}
17667    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
17668
17669    function showNoData(id, show) {{
17670      var el = document.getElementById(id);
17671      if (!el) return;
17672      var wrap = el.previousElementSibling;
17673      el.style.display = show ? '' : 'none';
17674      if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
17675    }}
17676
17677    // Shared hover treatment for every single-series bar/doughnut chart on this page:
17678    // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
17679    // treatment used by the language charts on the scan results page.
17680    function tmFadeColor(c) {{
17681      if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
17682      return c;
17683    }}
17684    function tmApplyFade(chart, activeIdx) {{
17685      var ds = chart.data.datasets[0];
17686      if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
17687      if (activeIdx == null) {{
17688        ds.backgroundColor = ds._baseBg.slice();
17689      }} else {{
17690        ds.backgroundColor = ds._baseBg.map(function(c, i) {{
17691          return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
17692        }});
17693      }}
17694    }}
17695    function tmFadeHover(e, active, chart) {{
17696      var t = e.native && e.native.target;
17697      if (t) t.style.cursor = active.length ? 'pointer' : 'default';
17698      var idx = active.length ? active[0].index : null;
17699      if (chart._fadeIdx === idx) return;
17700      chart._fadeIdx = idx;
17701      tmApplyFade(chart, idx);
17702      // 'active' mode tweens the fade + the hovered bar's pop via the fast active
17703      // transition (doughnuts keep their own hoverOffset motion regardless).
17704      chart.update('active');
17705    }}
17706    // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
17707    function tmDoughnutLegendHover(e, item, leg) {{
17708      var ch = leg.chart;
17709      var t = e.native && e.native.target;
17710      if (t) t.style.cursor = 'pointer';
17711      ch._fadeIdx = item.index;
17712      ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
17713      ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
17714      tmApplyFade(ch, item.index);
17715      ch.update();
17716    }}
17717    function tmDoughnutLegendLeave(e, item, leg) {{
17718      var ch = leg.chart;
17719      var t = e.native && e.native.target;
17720      if (t) t.style.cursor = 'default';
17721      ch._fadeIdx = null;
17722      ch.setActiveElements([]);
17723      ch.tooltip.setActiveElements([], {{}});
17724      tmApplyFade(ch, null);
17725      ch.update('none');
17726    }}
17727
17728    function renderTestCharts(D) {{
17729      currentLangTests = D || [];
17730      testsChart = destroyChart(testsChart);
17731      densityChart = destroyChart(densityChart);
17732      if (!D || !D.length) {{
17733        showNoData('no-data-tests', true);
17734        showNoData('no-data-density', true);
17735        return;
17736      }}
17737      showNoData('no-data-tests', false);
17738      showNoData('no-data-density', false);
17739      var top15 = D.slice(0, 15);
17740      var canvas1 = document.getElementById('canvas-tests');
17741      if (canvas1) {{
17742        testsChart = new Chart(canvas1, {{
17743          type: 'bar',
17744          data: {{
17745            labels: top15.map(function(d){{ return d.lang; }}),
17746            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
17747          }},
17748          options: {{
17749            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17750            layout: {{ padding: {{ right: 64 }} }},
17751            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
17752            scales: {{
17753              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
17754              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17755            }}
17756          }},
17757          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
17758        }});
17759        ALL_CHARTS.push(testsChart);
17760      }}
17761      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
17762      var canvas2 = document.getElementById('canvas-density');
17763      if (canvas2) {{
17764        densityChart = new Chart(canvas2, {{
17765          type: 'bar',
17766          data: {{
17767            labels: topD.map(function(d){{ return d.lang; }}),
17768            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 }}]
17769          }},
17770          options: {{
17771            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17772            layout: {{ padding: {{ right: 64 }} }},
17773            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
17774            scales: {{
17775              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
17776              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17777            }}
17778          }},
17779          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
17780        }});
17781        ALL_CHARTS.push(densityChart);
17782      }}
17783    }}
17784
17785    function renderAssertionsChart(D) {{
17786      assertionsChart = destroyChart(assertionsChart);
17787      if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
17788      var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
17789      var canvas = document.getElementById('canvas-assertions');
17790      if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
17791      showNoData('no-data-assertions', false);
17792      assertionsChart = new Chart(canvas, {{
17793        type: 'bar',
17794        data: {{
17795          labels: top15.map(function(d){{ return d.lang; }}),
17796          datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
17797        }},
17798        options: {{
17799          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17800          layout: {{ padding: {{ right: 64 }} }},
17801          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
17802          scales: {{
17803            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
17804            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17805          }}
17806        }},
17807        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
17808      }});
17809      ALL_CHARTS.push(assertionsChart);
17810    }}
17811
17812    function renderSuitesChart(D) {{
17813      suitesChart = destroyChart(suitesChart);
17814      if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
17815      var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
17816      var canvas = document.getElementById('canvas-suites');
17817      if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
17818      showNoData('no-data-suites', false);
17819      suitesChart = new Chart(canvas, {{
17820        type: 'bar',
17821        data: {{
17822          labels: top15.map(function(d){{ return d.lang; }}),
17823          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 }}]
17824        }},
17825        options: {{
17826          responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17827          layout: {{ padding: {{ right: 64 }} }},
17828          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
17829          scales: {{
17830            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
17831            y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17832          }}
17833        }},
17834        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
17835      }});
17836      ALL_CHARTS.push(suitesChart);
17837    }}
17838
17839    function renderFilesChart(totals) {{
17840      filesChart = destroyChart(filesChart);
17841      var canvas = document.getElementById('canvas-files');
17842      if (!canvas) return;
17843      var testF = totals.test_files || 0;
17844      var totalF = totals.total_files || 0;
17845      var nonTest = Math.max(0, totalF - testF);
17846      if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
17847      showNoData('no-data-files', false);
17848      var dark = isDark();
17849      filesChart = new Chart(canvas, {{
17850        type: 'doughnut',
17851        data: {{
17852          labels: ['Test Files', 'Non-Test Files'],
17853          datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
17854        }},
17855        options: {{
17856          responsive: true, maintainAspectRatio: false, cutout: '62%',
17857          onHover: tmFadeHover,
17858          plugins: {{
17859            legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
17860              generateLabels: function(chart) {{
17861                var ds = chart.data.datasets[0];
17862                var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
17863                return chart.data.labels.map(function(lbl, i) {{
17864                  var val = ds.data[i] || 0;
17865                  var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
17866                  return {{
17867                    text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
17868                    fillStyle: ds.backgroundColor[i],
17869                    strokeStyle: ds.borderColor,
17870                    lineWidth: ds.borderWidth,
17871                    hidden: false,
17872                    index: i,
17873                    datasetIndex: 0
17874                  }};
17875                }});
17876              }}
17877            }},
17878              onHover: tmDoughnutLegendHover,
17879              onLeave: tmDoughnutLegendLeave
17880            }},
17881            tooltip: {{ callbacks: {{ label: function(ctx) {{
17882              var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
17883              return ' ' + fmtFull(v) + ' files (' + pct + '%)';
17884            }} }} }}
17885          }}
17886        }},
17887        plugins: [donutPctPlugin]
17888      }});
17889      ALL_CHARTS.push(filesChart);
17890    }}
17891
17892    function renderCompositionChart(totals) {{
17893      compositionChart = destroyChart(compositionChart);
17894      var canvas = document.getElementById('canvas-composition');
17895      if (!canvas) return;
17896      var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
17897      if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
17898      showNoData('no-data-composition', false);
17899      compositionChart = new Chart(canvas, {{
17900        type: 'bar',
17901        data: {{
17902          labels: ['Test Functions', 'Assertions', 'Test Suites'],
17903          datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
17904        }},
17905        options: {{
17906          responsive: true, maintainAspectRatio: false,
17907          onHover: tmFadeHover,
17908          layout: {{ padding: {{ top: 22 }} }},
17909          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
17910          scales: {{
17911            x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
17912            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
17913          }}
17914        }},
17915        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
17916      }});
17917      ALL_CHARTS.push(compositionChart);
17918    }}
17919
17920    function renderCovCharts(covD, tiers) {{
17921      covChart = destroyChart(covChart);
17922      tierChart = destroyChart(tierChart);
17923      var covCanvas = document.getElementById('canvas-cov');
17924      if (covCanvas && covD && covD.length) {{
17925        covChart = new Chart(covCanvas, {{
17926          type: 'bar',
17927          data: {{
17928            labels: covD.map(function(d){{ return d.lang; }}),
17929            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 }}]
17930          }},
17931          options: {{
17932            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17933            layout: {{ padding: {{ right: 52 }} }},
17934            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
17935            scales: {{
17936              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
17937              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17938            }}
17939          }},
17940          plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
17941        }});
17942        ALL_CHARTS.push(covChart);
17943      }}
17944      var tierCanvas = document.getElementById('canvas-cov-tiers');
17945      if (tierCanvas && tiers) {{
17946        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
17947        tierChart = new Chart(tierCanvas, {{
17948          type: 'doughnut',
17949          data: {{
17950            labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
17951            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
17952          }},
17953          options: {{
17954            responsive: true, maintainAspectRatio: false, cutout: '62%',
17955            onHover: tmFadeHover,
17956            plugins: {{
17957              legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
17958                onHover: tmDoughnutLegendHover,
17959                onLeave: tmDoughnutLegendLeave
17960              }},
17961              tooltip: {{ callbacks: {{ label: function(ctx) {{
17962                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
17963                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
17964              }} }} }}
17965            }}
17966          }},
17967          plugins: [donutPctPlugin]
17968        }});
17969        ALL_CHARTS.push(tierChart);
17970      }}
17971    }}
17972
17973    function buildLangTable(D) {{
17974      var tbody = document.getElementById('lang-tbody');
17975      if (!tbody) return;
17976      if (!D || !D.length) {{
17977        tbody.innerHTML = '<tr><td class="sx-f3ab2b4c" colspan="8" >No test definitions detected. Run a scan on a project with test files.</td></tr>';
17978        return;
17979      }}
17980      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
17981      tbody.innerHTML = D.map(function(d) {{
17982        var barW = Math.round(d.density / maxDensity * 120);
17983        return '<tr>' +
17984          '<td><strong>' + d.lang + '</strong></td>' +
17985          '<td class="num">' + fmtFull(d.tests) + '</td>' +
17986          '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
17987          '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
17988          '<td class="num">' + fmtFull(d.code) + '</td>' +
17989          '<td class="num">' + fmtFull(d.files) + '</td>' +
17990          '<td class="num">' + d.density.toFixed(2) + '</td>' +
17991          '<td><div class="density-bar-wrap"><div class="density-bar" data-sx-style="width:' + barW + 'px;"></div></div></td>' +
17992          '</tr>';
17993      }}).join('');
17994    }}
17995
17996    var covFileData = [];
17997    var covFileTier = 'all';
17998    var covFileSearch = '';
17999
18000    function pctBadge(pct) {{
18001      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
18002      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
18003      return '<span class="cov-pct-badge" data-sx-style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
18004    }}
18005
18006    function buildCovFileTable() {{
18007      var tbody = document.getElementById('cov-file-tbody');
18008      var empty = document.getElementById('cov-file-empty');
18009      var count = document.getElementById('cov-file-count');
18010      if (!tbody) return;
18011      var srch = covFileSearch.toLowerCase();
18012      var filtered = covFileData.filter(function(f) {{
18013        if (covFileTier === 'zero' && f.line_pct > 0) return false;
18014        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
18015        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
18016        if (covFileTier === 'high' && f.line_pct < 80) return false;
18017        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
18018        return true;
18019      }});
18020      if (!filtered.length) {{
18021        tbody.innerHTML = '';
18022        if (empty) empty.style.display = '';
18023        if (count) count.textContent = '';
18024        return;
18025      }}
18026      if (empty) empty.style.display = 'none';
18027      var shown = Math.min(filtered.length, 500);
18028      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
18029      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
18030        var fnCol = f.fn_pct < 0
18031          ? '<td class="num sx-eb12fd52" >\u2014</td><td class="num sx-eb12fd52" >\u2014</td>'
18032          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num sx-eb12fd52" >' + f.fhit + ' / ' + f.ffound + '</td>';
18033        return '<tr>' +
18034          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
18035          '<td class="sx-db9c1998" >' + f.lang + '</td>' +
18036          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
18037          '<td class="num sx-eb12fd52" >' + f.lhit + ' / ' + f.lfound + '</td>' +
18038          fnCol +
18039          '</tr>';
18040      }}).join('');
18041    }}
18042
18043    (function() {{
18044      var tabs = document.getElementById('cov-filter-tabs');
18045      if (tabs) {{
18046        tabs.addEventListener('click', function(e) {{
18047          var btn = e.target.closest('.cov-tab');
18048          if (!btn) return;
18049          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
18050          btn.classList.add('active');
18051          covFileTier = btn.getAttribute('data-tier');
18052          buildCovFileTable();
18053        }});
18054      }}
18055      var srch = document.getElementById('cov-file-search');
18056      if (srch) {{
18057        srch.addEventListener('input', function() {{
18058          covFileSearch = this.value;
18059          buildCovFileTable();
18060        }});
18061      }}
18062    }})();
18063
18064    function updateCovGauges(t) {{
18065      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
18066      var el;
18067      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
18068      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
18069      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
18070      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
18071      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
18072      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
18073    }}
18074
18075    function applyScope() {{
18076      var d = getDataset();
18077      var t = d.totals;
18078      var el;
18079      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
18080      if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
18081      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
18082      if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
18083      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
18084      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
18085      if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
18086      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
18087      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
18088      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
18089      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
18090      renderTestCharts(d.lang_tests);
18091      renderAssertionsChart(d.lang_tests);
18092      renderSuitesChart(d.lang_tests);
18093      renderFilesChart(t);
18094      renderCompositionChart(t);
18095      buildLangTable(d.lang_tests);
18096      var covPanel = document.getElementById('cov-panel');
18097      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
18098      if (d.has_coverage) {{
18099        renderCovCharts(d.cov, d.cov_tiers);
18100        updateCovGauges(t);
18101        covFileData = d.file_cov || [];
18102        covFileTier = 'all';
18103        covFileSearch = '';
18104        var tabs = document.getElementById('cov-filter-tabs');
18105        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
18106        var srch = document.getElementById('cov-file-search');
18107        if (srch) srch.value = '';
18108        buildCovFileTable();
18109      }}
18110      loadTrend();
18111    }}
18112
18113    // Populate scope-root-sel from SCOPE_DATA keys
18114    (function() {{
18115      var sel = document.getElementById('scope-root-sel');
18116      if (!sel) return;
18117      Object.keys(SCOPE_DATA).forEach(function(k) {{
18118        if (k === '__all__') return;
18119        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
18120      }});
18121    }})();
18122
18123    document.getElementById('scope-root-sel').addEventListener('change', function() {{
18124      currentRoot = this.value;
18125      currentSub = '';
18126      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
18127      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
18128      var subWrap = document.getElementById('scope-sub-wrap');
18129      var subSel  = document.getElementById('scope-sub-sel');
18130      subSel.innerHTML = '<option value="">Entire project</option>';
18131      if (subNames.length) {{
18132        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
18133        subWrap.style.display = 'flex';
18134      }} else {{
18135        subWrap.style.display = 'none';
18136      }}
18137      applyScope();
18138    }});
18139
18140    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
18141      currentSub = this.value;
18142      applyScope();
18143    }});
18144
18145    var allTrendData = [];
18146
18147    var TM_Y_META = {{
18148      test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
18149      code_lines:  {{ label: 'Code Lines',       color: '#2A6846', tooltip: ' code lines' }}
18150    }};
18151
18152    // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
18153    function hexRgb(hex) {{
18154      var h = String(hex).replace('#', '');
18155      if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
18156      var n = parseInt(h, 16);
18157      return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
18158    }}
18159    // Vertical area-fill gradient matching the inline trend chart: fades from a soft
18160    // tint at the top to transparent at the bottom (no flat solid block).
18161    function tmTrendGradient(ctx2, chartArea, color) {{
18162      var rgb = hexRgb(color);
18163      var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
18164      g.addColorStop(0,   'rgba(' + rgb + ',0.28)');
18165      g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
18166      g.addColorStop(1,   'rgba(' + rgb + ',0)');
18167      return g;
18168    }}
18169
18170    // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
18171    // so linear interpolation between adjacent points matches the drawn line).
18172    function tmLineYAt(chart, px) {{
18173      var meta = chart.getDatasetMeta(0);
18174      if (!meta || !meta.data || !meta.data.length) return null;
18175      var d = meta.data;
18176      if (px <= d[0].x) return d[0].y;
18177      for (var i = 1; i < d.length; i++) {{
18178        if (px <= d[i].x) {{
18179          var span = d[i].x - d[i - 1].x;
18180          var t = span > 0 ? (px - d[i - 1].x) / span : 0;
18181          return d[i - 1].y + t * (d[i].y - d[i - 1].y);
18182        }}
18183      }}
18184      return d[d.length - 1].y;
18185    }}
18186
18187    // Plugin: only show the tooltip / finger cursor when the pointer is over the
18188    // gradient fill (inside the plot and at/below the line) — never in the empty
18189    // space above the line. Outside the fill we retype the event as 'mouseout' so
18190    // the core interaction dismisses any active tooltip on its own.
18191    var tmFillGuard = {{
18192      id: 'tmFillGuard',
18193      beforeEvent: function(chart, args) {{
18194        var e = args.event;
18195        if (!e || e.type !== 'mousemove') return;
18196        var ca = chart.chartArea;
18197        if (!ca) return;
18198        var inFill = false;
18199        if (e.x >= ca.left && e.x <= ca.right) {{
18200          var ly = tmLineYAt(chart, e.x);
18201          if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
18202        }}
18203        if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
18204        if (!inFill) {{ e.type = 'mouseout'; }}
18205      }}
18206    }};
18207
18208    // Single source of truth for the test-metrics trend chart config so the inline
18209    // chart and the Full View modal render identically (straight segments, gradient
18210    // fill, white-ringed points, gradient-only interactivity).
18211    function buildTmTrendConfig(pts, ctrl, meta) {{
18212      return {{
18213        type: 'line',
18214        data: {{
18215          labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
18216          datasets: [{{
18217            label: meta.label,
18218            data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
18219            borderColor: meta.color,
18220            borderWidth: 2.5,
18221            backgroundColor: function(context) {{
18222              var ca = context.chart.chartArea;
18223              if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
18224              return tmTrendGradient(context.chart.ctx, ca, meta.color);
18225            }},
18226            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
18227            pointBorderColor: '#fff',
18228            pointBorderWidth: 2,
18229            pointRadius: 6,
18230            pointHoverRadius: 9,
18231            pointHoverBorderWidth: 2.5,
18232            fill: true, tension: 0
18233          }}]
18234        }},
18235        options: {{
18236          responsive: true, maintainAspectRatio: false,
18237          layout: {{ padding: {{ top: 22 }} }},
18238          interaction: {{ mode: 'index', intersect: false }},
18239          plugins: {{
18240            legend: {{ display: false }},
18241            tooltip: {{
18242              mode: 'index', intersect: false,
18243              callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
18244            }}
18245          }},
18246          scales: {{
18247            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
18248            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
18249          }}
18250        }},
18251        plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
18252      }};
18253    }}
18254
18255    function getTrendControls() {{
18256      var ySel    = document.getElementById('tm-trend-y');
18257      var xSel    = document.getElementById('tm-trend-x');
18258      var sizeSel = document.getElementById('tm-trend-size');
18259      var subSel  = document.getElementById('tm-trend-sub');
18260      return {{
18261        yKey:    ySel    ? ySel.value    : 'test_count',
18262        xMode:   xSel    ? xSel.value    : 'commit',
18263        height:  sizeSel ? parseInt(sizeSel.value, 10) : 260,
18264        submod:  subSel  ? subSel.value  : ''
18265      }};
18266    }}
18267
18268    function makeTrendLabel(d, xMode) {{
18269      if (xMode === 'commit') {{
18270        return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
18271      }}
18272      return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
18273    }}
18274
18275    function buildTrend(data) {{
18276      allTrendData = data || [];
18277      renderTrend();
18278    }}
18279
18280    function renderTrend() {{
18281      var data = allTrendData;
18282      var ctrl = getTrendControls();
18283      var trendCanvas = document.getElementById('canvas-trend');
18284      var trendWrap   = document.getElementById('trend-canvas-wrap');
18285      var trendEmpty  = document.getElementById('trend-empty');
18286
18287      // Apply chart size
18288      if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
18289
18290      // Filter by submodule if selected (entries from project_label match)
18291      var pts = data.slice().reverse();
18292      if (ctrl.submod) {{
18293        pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
18294      }}
18295
18296      currentTrendPts = pts;
18297
18298      if (!pts.length) {{
18299        if (trendCanvas) trendCanvas.style.display = 'none';
18300        if (trendEmpty) trendEmpty.style.display = '';
18301        return;
18302      }}
18303      if (trendCanvas) trendCanvas.style.display = '';
18304      if (trendEmpty) trendEmpty.style.display = 'none';
18305
18306      trendChart = destroyChart(trendChart);
18307      if (!trendCanvas) return;
18308
18309      var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
18310
18311      trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
18312      trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
18313      ALL_CHARTS.push(trendChart);
18314
18315      // Populate submodule selector from unique project_labels
18316      var subSel = document.getElementById('tm-trend-sub');
18317      var subLabel = document.getElementById('tm-sub-label');
18318      if (subSel && data.length) {{
18319        var projects = [];
18320        data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
18321        if (projects.length > 1) {{
18322          var curVal = subSel.value;
18323          subSel.innerHTML = '<option value="">All (project total)</option>';
18324          projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'&quot;')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
18325          if (subLabel) subLabel.style.display = '';
18326        }} else {{
18327          if (subLabel) subLabel.style.display = 'none';
18328        }}
18329      }}
18330    }}
18331
18332    // ── Full View expand buttons ──────────────────────────────────────────────
18333    (function() {{
18334      var btn = document.getElementById('tests-expand-btn');
18335      if (!btn) return;
18336      btn.addEventListener('click', function() {{
18337        var D = currentLangTests;
18338        if (!D || !D.length) return;
18339        var top15 = D.slice(0, 15);
18340        var h = Math.max(320, top15.length * 36 + 80);
18341        var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
18342        if (!canvas) return;
18343        new Chart(canvas, {{
18344          type: 'bar',
18345          data: {{
18346            labels: top15.map(function(d){{ return d.lang; }}),
18347            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
18348          }},
18349          options: {{
18350            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18351            layout: {{ padding: {{ right: 72 }} }},
18352            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
18353            scales: {{
18354              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
18355              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18356            }}
18357          }},
18358          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
18359        }});
18360      }});
18361    }})();
18362
18363    (function() {{
18364      var btn = document.getElementById('density-expand-btn');
18365      if (!btn) return;
18366      btn.addEventListener('click', function() {{
18367        var D = currentLangTests;
18368        if (!D || !D.length) return;
18369        var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
18370        var h = Math.max(320, topD.length * 36 + 80);
18371        var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
18372        if (!canvas) return;
18373        new Chart(canvas, {{
18374          type: 'bar',
18375          data: {{
18376            labels: topD.map(function(d){{ return d.lang; }}),
18377            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 }}]
18378          }},
18379          options: {{
18380            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18381            layout: {{ padding: {{ right: 72 }} }},
18382            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
18383            scales: {{
18384              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
18385              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18386            }}
18387          }},
18388          plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
18389        }});
18390      }});
18391    }})();
18392
18393    (function() {{
18394      var btn = document.getElementById('trend-expand-btn');
18395      if (!btn) return;
18396      btn.addEventListener('click', function() {{
18397        var pts = currentTrendPts;
18398        if (!pts || !pts.length) return;
18399        var ctrl = getTrendControls();
18400        var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
18401        var title = meta.label + ' Trend \u2014 Full View';
18402        var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
18403        if (!canvas) return;
18404        // Reuse the exact inline-chart config so Full View matches the default view
18405        // (straight segments + gradient-only interactivity), just larger.
18406        new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
18407      }});
18408    }})();
18409
18410    (function() {{
18411      var btn = document.getElementById('assertions-expand-btn');
18412      if (!btn) return;
18413      btn.addEventListener('click', function() {{
18414        var D = currentLangTests;
18415        if (!D || !D.length) return;
18416        var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
18417        if (!top15.length) return;
18418        var h = Math.max(320, top15.length * 36 + 80);
18419        var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
18420        if (!canvas) return;
18421        new Chart(canvas, {{
18422          type: 'bar',
18423          data: {{
18424            labels: top15.map(function(d){{ return d.lang; }}),
18425            datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
18426          }},
18427          options: {{
18428            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18429            layout: {{ padding: {{ right: 72 }} }},
18430            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
18431            scales: {{
18432              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
18433              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18434            }}
18435          }},
18436          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
18437        }});
18438      }});
18439    }})();
18440
18441    (function() {{
18442      var btn = document.getElementById('suites-expand-btn');
18443      if (!btn) return;
18444      btn.addEventListener('click', function() {{
18445        var D = currentLangTests;
18446        if (!D || !D.length) return;
18447        var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
18448        if (!top15.length) return;
18449        var h = Math.max(320, top15.length * 36 + 80);
18450        var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
18451        if (!canvas) return;
18452        new Chart(canvas, {{
18453          type: 'bar',
18454          data: {{
18455            labels: top15.map(function(d){{ return d.lang; }}),
18456            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 }}]
18457          }},
18458          options: {{
18459            responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18460            layout: {{ padding: {{ right: 72 }} }},
18461            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
18462            scales: {{
18463              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
18464              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18465            }}
18466          }},
18467          plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
18468        }});
18469      }});
18470    }})();
18471
18472    // Wire trend control selectors — re-render without re-fetching
18473    (function() {{
18474      ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
18475        var el = document.getElementById(id);
18476        if (el) el.addEventListener('change', function() {{ renderTrend(); }});
18477      }});
18478    }})();
18479
18480    function loadTrend() {{
18481      var url = '/api/metrics/history?limit=100';
18482      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
18483      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
18484        buildTrend(data);
18485        // Show Multi-Timeline button when >= 2 scans exist for the selected project.
18486        var btn = document.getElementById('multi-compare-trend-btn');
18487        if (btn) {{
18488          var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
18489          if (ids.length >= 2) {{
18490            btn.style.display = '';
18491            btn.onclick = function() {{
18492              // Reverse so oldest first (API returns newest first).
18493              var sorted = ids.slice().reverse();
18494              if (sorted.length === 2) {{
18495                window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
18496              }} else {{
18497                window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
18498              }}
18499            }};
18500          }} else {{
18501            btn.style.display = 'none';
18502          }}
18503        }}
18504      }}).catch(function(){{
18505        var trendEmpty = document.getElementById('trend-empty');
18506        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
18507      }});
18508    }}
18509
18510    // Re-render charts on theme toggle
18511    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
18512      setTimeout(function() {{
18513        ALL_CHARTS.forEach(function(c) {{
18514          if (c && c.options && c.options.scales) {{
18515            Object.values(c.options.scales).forEach(function(ax) {{
18516              if (ax.grid) ax.grid.color = clr();
18517              if (ax.ticks) ax.ticks.color = txtClr();
18518            }});
18519            c.update();
18520          }}
18521        }});
18522      }}, 80);
18523    }});
18524
18525    // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
18526    var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18527    function tmExportMeta() {{
18528      var sel = document.getElementById('scope-sel');
18529      var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
18530      if (!proj || proj === '__all__') proj = 'All projects';
18531      var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
18532      var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
18533      var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
18534      var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
18535      return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
18536    }}
18537
18538    function exportTmXLSX() {{
18539      var D = currentLangTests;
18540      if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
18541      var t = tmExportMeta();
18542      function s2b(s) {{ return new TextEncoder().encode(s); }}
18543      function xe(s) {{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }}
18544      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; }}
18545      function crc32(d) {{
18546        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;}}}}
18547        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
18548      }}
18549      // Store all cells as strings so Excel left-aligns uniformly.
18550      function cs(addr, val, bold) {{
18551        return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
18552      }}
18553      // Build an Excel Table XML definition for a given sheet range and columns.
18554      function makeTableXml(tblId, name, ref, cols) {{
18555        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
18556        x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
18557        x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
18558        x+='<autoFilter ref="'+ref+'"/>';
18559        x+='<tableColumns count="'+cols.length+'">';
18560        cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
18561        x+='</tableColumns>';
18562        x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
18563        return x+'</table>';
18564      }}
18565      // Worksheet XML with optional Excel Table part reference.
18566      function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
18567        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
18568        if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
18569        var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
18570        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
18571        x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
18572        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>';}});
18573        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>';}}
18574        x+='</sheetData>';
18575        if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
18576        return x+'</worksheet>';
18577      }}
18578
18579      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
18580      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
18581      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
18582      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
18583      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
18584      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
18585
18586      // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
18587      // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
18588      var sheets=[];
18589
18590      // Sheet: Summary
18591      var sumHdr=['Metric','Value'];
18592      var sumRows=[
18593        ['Project / Scope', t.proj],
18594        ['Export Date', t.full],
18595        ['Test Functions', Number(totTests).toLocaleString()],
18596        ['Assertions', Number(totAssert).toLocaleString()],
18597        ['Test Suites', Number(totSuites).toLocaleString()],
18598        ['Languages with Tests', String(D.length)],
18599        ['Total Code Lines', Number(totCode).toLocaleString()],
18600        ['Average Density (per 1K)', String(avgDensity)],
18601      ];
18602      sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
18603
18604      // Sheet: Language Breakdown (TOTAL row sits just below the table range)
18605      var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
18606      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)];}});
18607      var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
18608      sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
18609
18610      // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
18611      var covDs=(typeof getDataset==='function')?getDataset():null;
18612      if(covDs&&covDs.has_coverage){{
18613        var covT=covDs.totals||{{}};
18614        var covSumHdr=['Metric','Value'];
18615        var covSumRows=[
18616          ['Line Coverage', (covT.cov_line||'0')+'%'],
18617          ['Function Coverage', (covT.cov_fn||'0')+'%'],
18618          ['Branch Coverage', (covT.cov_branch||'0')+'%'],
18619        ];
18620        if(covDs.cov_tiers){{
18621          covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
18622          covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
18623          covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
18624        }}
18625        sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
18626
18627        if(covDs.cov&&covDs.cov.length){{
18628          var covLangHdr=['Language','Line Coverage %'];
18629          var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
18630          sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
18631        }}
18632        if(covFileData&&covFileData.length){{
18633          var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
18634          var covFileRows=covFileData.map(function(f){{
18635            var noFn=f.fn_pct<0;
18636            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)];
18637          }});
18638          sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
18639        }}
18640      }}
18641
18642      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>';
18643      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>';
18644
18645      // Assemble per-sheet parts, content-type overrides, and workbook relationships.
18646      var files=[];
18647      var ctOverrides='', wbSheetTags='', wbRelTags='';
18648      sheets.forEach(function(sh,i){{
18649        var n=i+1;
18650        var lastCol=col2l(sh.hdr.length);
18651        var ref='A1:'+lastCol+(sh.rows.length+1);
18652        var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
18653        var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
18654        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>';
18655        files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
18656        files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
18657        files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
18658        ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
18659        ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
18660        wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
18661        wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
18662      }});
18663      var styleRid='rId'+(sheets.length+1);
18664      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>';
18665      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>';
18666      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>';
18667      files.unshift(
18668        {{name:'[Content_Types].xml',data:s2b(ct)}},
18669        {{name:'_rels/.rels',data:s2b(dotrels)}},
18670        {{name:'xl/workbook.xml',data:s2b(wbx)}},
18671        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
18672        {{name:'xl/styles.xml',data:s2b(styl)}}
18673      );
18674      var parts=[],offsets=[],total=0;
18675      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;}});
18676      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;}});
18677      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));
18678      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;}});
18679      var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
18680      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
18681      a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
18682      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
18683    }}
18684
18685    function exportTmPNG() {{
18686      // Map canvas IDs to display titles
18687      var CHART_TITLES = {{
18688        'canvas-trend':       'TEST COUNT TREND',
18689        'canvas-tests':       'TEST DEFINITIONS BY LANGUAGE',
18690        'canvas-density':     'TEST DENSITY (per 1,000 code lines)',
18691        'canvas-assertions':  'ASSERTIONS BY LANGUAGE',
18692        'canvas-suites':      'TEST SUITES BY LANGUAGE',
18693        'canvas-files':       'TEST FILES BREAKDOWN',
18694        'canvas-composition': 'TEST COMPOSITION',
18695        'canvas-cov':         'LINE COVERAGE % BY LANGUAGE',
18696        'canvas-cov-tiers':   'COVERAGE TIER DISTRIBUTION'
18697      }};
18698      // Coverage canvases are only appended when the LCOV panel is visible (has data).
18699      var covPanelEl=document.getElementById('cov-panel');
18700      var covShown=covPanelEl&&covPanelEl.style.display!=='none';
18701      var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
18702      if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
18703      // Include only charts that actually rendered data. A "no data" chart has its
18704      // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
18705      // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
18706      function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
18707      var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
18708      if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
18709      var t=tmExportMeta();
18710      var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
18711      var trendCanvas=document.getElementById('canvas-trend');
18712      var hasTrend=chartHasData(trendCanvas);
18713      var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
18714      var TOTAL_W=COLW*2+GAP;
18715      var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
18716      TREND_H=Math.min(Math.max(200,TREND_H),340);
18717      // Per-row chart heights (2-col grid)
18718      var gridRows=Math.ceil(gridCanvases.length/2);
18719      var rowHeights=[];
18720      for(var ri=0;ri<gridRows;ri++){{
18721        var rh=240;
18722        for(var ci=0;ci<2;ci++){{
18723          var cv=gridCanvases[ri*2+ci];
18724          if(cv&&cv.width>0){{
18725            var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
18726            rh=Math.max(rh,Math.min(420,nat));
18727          }}
18728        }}
18729        rowHeights.push(rh);
18730      }}
18731      var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
18732      var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
18733      var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
18734      var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
18735      var ctx=out.getContext('2d');
18736      var cs2=getComputedStyle(document.body);
18737      var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
18738      var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
18739      var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
18740
18741      // Background
18742      ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
18743
18744      // Orange header block
18745      ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
18746      ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
18747      ctx.fillText('Test Metrics — '+t.proj,22,42);
18748      ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
18749      ctx.fillText('oxide-sloc v{version}  ·  Generated '+t.full,22,70);
18750      ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
18751
18752      // Helper: draw a section title label
18753      function drawTitle(label, x, y, w) {{
18754        ctx.save();
18755        ctx.fillStyle=oxide;
18756        ctx.font='700 11px '+TM_FONT;
18757        ctx.textBaseline='middle';
18758        ctx.textAlign='left';
18759        ctx.letterSpacing='0.07em';
18760        ctx.fillText(label, x+2, y+TITLE_H/2);
18761        // Underline
18762        ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
18763        ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
18764        ctx.globalAlpha=1;
18765        ctx.restore();
18766      }}
18767
18768      var yOff=HEADER_H;
18769
18770      // Trend chart (full width)
18771      if(hasTrend){{
18772        drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
18773        yOff+=TITLE_H;
18774        var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
18775        var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
18776        sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
18777        ctx.drawImage(surf,0,yOff);
18778        yOff+=TREND_H+ROW_PAD;
18779      }}
18780
18781      // Grid charts (2-col), each cell gets title + chart
18782      for(var gi=0;gi<gridRows;gi++){{
18783        var rh2=rowHeights[gi];
18784        // Draw row titles and charts
18785        for(var gci=0;gci<2;gci++){{
18786          var idx2=gi*2+gci;
18787          if(idx2>=gridCanvases.length)continue;
18788          var gcv=gridCanvases[idx2];
18789          var gx=gci*(COLW+GAP);
18790          drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
18791        }}
18792        yOff+=TITLE_H;
18793        for(var gci2=0;gci2<2;gci2++){{
18794          var idx3=gi*2+gci2;
18795          if(idx3>=gridCanvases.length)continue;
18796          var gcv2=gridCanvases[idx3];
18797          var gx2=gci2*(COLW+GAP);
18798          var natW=gcv2.width,natH=gcv2.height;
18799          var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
18800          var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
18801          var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
18802          var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
18803          sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
18804          ctx.drawImage(surf2,gx2,yOff);
18805        }}
18806        yOff+=rh2+ROW_PAD;
18807      }}
18808
18809      // Dark footer
18810      ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
18811      ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
18812      ctx.fillText('© 2026 OxideSLOC  ·  oxide-sloc v{version}  ·  AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
18813
18814      var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
18815      var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
18816    }}
18817
18818    function exportTmPDF(ev) {{
18819      var D=currentLangTests;
18820      var t=tmExportMeta();
18821      var strips=document.querySelectorAll('.summary-strip');
18822      var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
18823      var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
18824      var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
18825      var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
18826      var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
18827      var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
18828      var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
18829      var rows='';
18830      (D||[]).forEach(function(d){{
18831        rows+='<tr><td><strong>'+d.lang+'</strong></td>'
18832          +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
18833          +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
18834          +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
18835          +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
18836          +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
18837          +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
18838      }});
18839      var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
18840        +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
18841        +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
18842        +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
18843        +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
18844        +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
18845        +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
18846      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>';
18847      var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
18848        +'html,body{{height:100%;margin:0;}}'
18849        +'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;}}'
18850        +'.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;}}'
18851        +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
18852        +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
18853        +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
18854        +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
18855        +'.rep-body{{padding:20px 32px;flex:1;}}'
18856        +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
18857        +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
18858        +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
18859        +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
18860        +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
18861        +'.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;}}'
18862        +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
18863        +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
18864        +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
18865        +'.n{{text-align:right;}}'
18866        +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
18867        +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
18868        +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
18869        +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
18870        +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
18871        +'.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;}}'
18872        +'</style>';
18873      // LCOV Coverage Summary section — only rendered when the current scope has coverage.
18874      var covDs=(typeof getDataset==='function')?getDataset():null;
18875      var covHtml='';
18876      if(covDs&&covDs.has_coverage){{
18877        var covT=covDs.totals||{{}};
18878        covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
18879          +'<div class="cov-strip">'
18880          +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
18881          +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
18882          +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
18883          +'</div>';
18884        if(covFileData&&covFileData.length){{
18885          var cfrows='';
18886          covFileData.forEach(function(f){{
18887            var noFn=f.fn_pct<0;
18888            cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
18889              +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
18890              +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
18891              +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
18892              +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
18893          }});
18894          covHtml+='<div class="section-hdr">Coverage File Detail</div>'
18895            +'<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>';
18896        }}
18897      }}
18898      var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
18899        +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+'  ·  Generated: '+t.full+'</p></div>'
18900        +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
18901        +'<div class="rep-body">'+statsHtml
18902        +'<div class="section-hdr">Language Breakdown</div>'
18903        +tableHtml+covHtml+'</div>'
18904        +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
18905        +'</body></html>';
18906      var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
18907      var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
18908      window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
18909    }}
18910
18911    (function() {{
18912      // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
18913      // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
18914      var xBtn=document.getElementById('tm-export-xlsx-btn');
18915      var pngBtn=document.getElementById('tm-export-png-btn');
18916      var pdfBtn=document.getElementById('tm-export-pdf-btn');
18917      if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
18918      if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
18919      if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
18920    }})();
18921
18922    applyScope();
18923  }})();
18924  </script>
18925  <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>
18926  {toast_assets}
18927</body>
18928</html>"#,
18929    );
18930    (
18931        [(axum::http::header::CACHE_CONTROL, "no-store")],
18932        Html(html),
18933    )
18934        .into_response()
18935}
18936
18937// ── Embeddable widget ─────────────────────────────────────────────────────────
18938// Protected. Returns a self-contained HTML page suitable for iframing inside
18939// Jenkins build summaries, Confluence iframe macros, or Jira panels.
18940//
18941// GET /embed/summary?run_id=<uuid>&theme=dark
18942
18943#[derive(Deserialize)]
18944struct EmbedQuery {
18945    run_id: Option<String>,
18946    theme: Option<String>,
18947}
18948
18949async fn embed_handler(
18950    State(state): State<AppState>,
18951    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
18952    Query(query): Query<EmbedQuery>,
18953) -> Response {
18954    let entry = {
18955        let reg = state.registry.lock().await;
18956        query.run_id.as_ref().map_or_else(
18957            || reg.entries.first().cloned(),
18958            |id| reg.find_by_run_id(id).cloned(),
18959        )
18960    };
18961
18962    let Some(entry) = entry else {
18963        return Html(
18964            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
18965                .to_string(),
18966        )
18967        .into_response();
18968    };
18969
18970    let dark = query.theme.as_deref() == Some("dark");
18971    let languages: Vec<(String, u64, u64)> = entry
18972        .json_path
18973        .as_ref()
18974        .and_then(|p| read_json(p).ok())
18975        .map(|run| {
18976            run.totals_by_language
18977                .iter()
18978                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
18979                .collect()
18980        })
18981        .unwrap_or_default();
18982
18983    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
18984}
18985
18986fn render_embed_widget(
18987    entry: &RegistryEntry,
18988    languages: &[(String, u64, u64)],
18989    dark: bool,
18990    csp_nonce: &str,
18991) -> String {
18992    let s = &entry.summary;
18993    let total = s.code_lines + s.comment_lines + s.blank_lines;
18994    let code_pct = s
18995        .code_lines
18996        .checked_mul(100)
18997        .and_then(|n| n.checked_div(total))
18998        .unwrap_or(0);
18999
19000    let (bg, fg, surface, muted, border) = if dark {
19001        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
19002    } else {
19003        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
19004    };
19005
19006    let mut lang_rows = String::new();
19007    for (name, files, code) in languages {
19008        write!(
19009            lang_rows,
19010            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
19011            escape_html(name),
19012            format_number(*files),
19013            format_number(*code),
19014        )
19015        .ok();
19016    }
19017
19018    let lang_table = if lang_rows.is_empty() {
19019        String::new()
19020    } else {
19021        format!(
19022            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
19023        )
19024    };
19025
19026    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
19027    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
19028    let project_esc = escape_html(&entry.project_label);
19029    let code_lines = format_number(s.code_lines);
19030    let comment_lines = format_number(s.comment_lines);
19031    let files = format_number(s.files_analyzed);
19032    let code_raw = s.code_lines;
19033    let comment_raw = s.comment_lines;
19034    let blank_raw = s.blank_lines;
19035
19036    format!(
19037        r#"<!doctype html>
19038<html lang="en">
19039<head>
19040  <meta charset="utf-8">
19041  <meta name="viewport" content="width=device-width,initial-scale=1">
19042  <title>OxideSLOC &mdash; {project_esc}</title>
19043  <script src="/static/chart.js"></script>
19044  <style nonce="{csp_nonce}">
19045    *{{box-sizing:border-box;margin:0;padding:0}}
19046    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
19047    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
19048    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
19049    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
19050    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
19051    .card .v{{font-size:18px;font-weight:700}}
19052    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
19053    .row{{display:flex;gap:12px;align-items:flex-start}}
19054    .pie{{width:120px;height:120px;flex-shrink:0}}
19055    .lt{{border-collapse:collapse;width:100%;flex:1}}
19056    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
19057    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
19058    .n{{text-align:right}}
19059    .footer{{margin-top:10px;color:{muted};font-size:10px}}
19060  </style>
19061</head>
19062<body>
19063  <h2>{project_esc}</h2>
19064  <div class="sub">{timestamp} &middot; run {run_short}</div>
19065  <div class="cards">
19066    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
19067    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
19068    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
19069    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
19070  </div>
19071  <div class="row">
19072    <canvas class="pie" id="c"></canvas>
19073    {lang_table}
19074  </div>
19075  <div class="footer">oxide-sloc</div>
19076  <script nonce="{csp_nonce}">
19077    new Chart(document.getElementById('c'),{{
19078      type:'doughnut',
19079      data:{{
19080        labels:['Code','Comments','Blank'],
19081        datasets:[{{
19082          data:[{code_raw},{comment_raw},{blank_raw}],
19083          backgroundColor:['#4a78ee','#b35428','#aaa'],
19084          borderWidth:0
19085        }}]
19086      }},
19087      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
19088    }});
19089  </script>
19090</body>
19091</html>"#
19092    )
19093}
19094
19095/// Returns a process-wide mutex unique to `dir`, so that two requests writing
19096/// artifacts into the *same* output directory (e.g. re-ingesting an identical
19097/// `run_id`) serialize instead of corrupting each other's files. Directories that
19098/// differ never contend, so legitimate parallel analyses keep their throughput.
19099fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
19100    static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
19101        OnceLock::new();
19102    let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
19103    let mut guard = map
19104        .lock()
19105        .unwrap_or_else(std::sync::PoisonError::into_inner);
19106    guard
19107        .entry(dir.to_path_buf())
19108        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
19109        .clone()
19110}
19111
19112#[allow(clippy::too_many_lines)]
19113fn persist_run_artifacts(
19114    run: &sloc_core::AnalysisRun,
19115    report_html: &str,
19116    run_dir: &Path,
19117    report_title: &str,
19118    file_stem: &str,
19119    result_context: RunResultContext,
19120) -> Result<(RunArtifacts, PendingPdf)> {
19121    // Serialize concurrent writers targeting this same output directory so their
19122    // file writes cannot interleave and corrupt one another.
19123    let dir_lock = output_dir_lock(run_dir);
19124    let _dir_guard = dir_lock
19125        .lock()
19126        .unwrap_or_else(std::sync::PoisonError::into_inner);
19127
19128    // Root dir + organised subdirectories.
19129    let html_dir = run_dir.join("html");
19130    let pdf_dir = run_dir.join("pdf");
19131    let excel_dir = run_dir.join("excel");
19132    let json_dir = run_dir.join("json");
19133    let submodules_dir = run_dir.join("submodules");
19134    for dir in &[
19135        run_dir,
19136        &html_dir,
19137        &pdf_dir,
19138        &excel_dir,
19139        &json_dir,
19140        &submodules_dir,
19141    ] {
19142        fs::create_dir_all(dir)
19143            .with_context(|| format!("failed to create directory {}", dir.display()))?;
19144    }
19145
19146    // HTML report in html/.
19147    let html_path = {
19148        let path = sloc_core::reject_traversal(&html_dir.join(format!("report_{file_stem}.html")))?;
19149        fs::write(&path, report_html)
19150            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
19151        Some(path)
19152    };
19153
19154    // JSON result in json/.
19155    let json_path = {
19156        let path = sloc_core::reject_traversal(&json_dir.join(format!("result_{file_stem}.json")))?;
19157        let json = serde_json::to_string_pretty(run)
19158            .context("failed to serialize analysis run to JSON")?;
19159        fs::write(&path, json)
19160            .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
19161        Some(path)
19162    };
19163
19164    // PDF in pdf/.
19165    let (pdf_path, pending_pdf) = {
19166        let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
19167        match write_pdf_from_run(run, &pdf_dest) {
19168            Ok(()) => {
19169                eprintln!(
19170                    "[oxide-sloc][pdf] native PDF written to {}",
19171                    pdf_dest.display()
19172                );
19173                (Some(pdf_dest), None)
19174            }
19175            Err(native_err) => {
19176                eprintln!(
19177                    "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
19178                );
19179                let source_html_path = html_path
19180                    .as_ref()
19181                    .expect("html_path always Some here")
19182                    .clone();
19183                let pending = Some((source_html_path, pdf_dest.clone(), false));
19184                (Some(pdf_dest), pending)
19185            }
19186        }
19187    };
19188
19189    // CSV and XLSX in excel/.
19190    let csv_path = {
19191        let path = excel_dir.join(format!("report_{file_stem}.csv"));
19192        match sloc_report::write_csv(run, &path) {
19193            Err(e) => {
19194                eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
19195                None
19196            }
19197            _ => Some(path),
19198        }
19199    };
19200
19201    let xlsx_path = {
19202        let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
19203        match sloc_report::write_xlsx(run, &path) {
19204            Err(e) => {
19205                eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
19206                None
19207            }
19208            _ => Some(path),
19209        }
19210    };
19211
19212    // Scan config in json/.
19213    let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
19214
19215    // Eagerly generate sub-reports before index.html so relative links work.
19216    if run.effective_configuration.discovery.submodule_breakdown {
19217        let run_id = &run.tool.run_id;
19218        for s in &run.submodule_summaries {
19219            build_submodule_row(s, run, run_id, run_dir);
19220        }
19221    }
19222
19223    // index.html at root — offline static export of the result-page dashboard.
19224    generate_offline_index(
19225        run,
19226        run_dir,
19227        file_stem,
19228        html_path.as_deref(),
19229        pdf_path.as_deref(),
19230        json_path.as_deref(),
19231        scan_config_path.as_deref(),
19232        &result_context,
19233    );
19234
19235    Ok((
19236        RunArtifacts {
19237            output_dir: run_dir.to_path_buf(),
19238            html_path,
19239            pdf_path,
19240            json_path,
19241            csv_path,
19242            xlsx_path,
19243            scan_config_path,
19244            report_title: report_title.to_string(),
19245            result_context,
19246        },
19247        pending_pdf,
19248    ))
19249}
19250
19251/// Materialize a completed [`AnalysisRun`] into the exact on-disk layout the local web UI
19252/// produces, then register it in `<out_root>/registry.json` so the local Compare / "Scan
19253/// Delta" page can pair it with other runs.
19254///
19255/// This is the shared entry point used by both the web scan flow (indirectly, via
19256/// [`persist_run_artifacts`]) and the `oxide-sloc bundle` CLI command, so the run-directory
19257/// layout and registry schema can never drift between the two.
19258///
19259/// Layout produced under `<out_root>/<project_label>_<run_id>/`:
19260/// - `index.html`                          — offline dashboard
19261/// - `html/report_<stem>.html`             — full HTML report
19262/// - `json/result_<stem>.json`             — the serialized `AnalysisRun`
19263/// - `json/scan-config_<stem>.json`        — scan configuration snapshot
19264/// - `pdf/report_<stem>.pdf`               — best-effort native PDF (skipped on failure)
19265/// - `excel/report_<stem>.csv` / `.xlsx`   — tabular exports
19266/// - `submodules/`                         — per-submodule sub-reports (when enabled)
19267///
19268/// `<stem>` is `<project_label>_<git_commit_short>` when a commit is known, else
19269/// `<project_label>`. Returns the created run-directory path.
19270///
19271/// # Errors
19272///
19273/// Returns an error if the HTML report cannot be rendered or the artifacts cannot be written.
19274pub fn bundle_run(
19275    run: &AnalysisRun,
19276    out_root: &Path,
19277    run_id: &str,
19278    label: Option<&str>,
19279) -> Result<PathBuf> {
19280    // Project label: caller override, else derived exactly as the web UI derives it from the
19281    // first input root (falling back to a generic slug when there are no roots).
19282    let project_label = match label.map(str::trim).filter(|s| !s.is_empty()) {
19283        Some(explicit) => sanitize_project_label(explicit),
19284        None => {
19285            let fallback = run.input_roots.first().map_or("", String::as_str);
19286            derive_project_label(None, None, fallback)
19287        }
19288    };
19289
19290    let run_dir = out_root.join(derive_run_dir_name(
19291        &project_label,
19292        run.git_branch.as_deref(),
19293        run_id,
19294    ));
19295    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
19296
19297    let report_html = render_html(run).context("failed to render HTML report for bundle output")?;
19298
19299    let project_path = run.input_roots.first().cloned().unwrap_or_default();
19300    let result_context = RunResultContext {
19301        prev_entry: None,
19302        prev_scan_count: 0,
19303        project_path: project_path.clone(),
19304        cocomo_mode: "organic".to_string(),
19305        complexity_alert: 0,
19306        exclude_duplicates: false,
19307    };
19308
19309    let (artifacts, _pending_pdf) = persist_run_artifacts(
19310        run,
19311        &report_html,
19312        &run_dir,
19313        &run.effective_configuration.reporting.report_title,
19314        &file_stem,
19315        result_context,
19316    )?;
19317
19318    // Write the scan-config snapshot into json/ (same file the web flow writes).
19319    if let Some(ref cfg_path) = artifacts.scan_config_path {
19320        save_scan_config_json(
19321            cfg_path,
19322            run,
19323            &project_path,
19324            out_root.to_str(),
19325            "organic",
19326            0,
19327            false,
19328        );
19329    }
19330
19331    // Register the run so the local Compare page can find and pair it.
19332    let registry_path = out_root.join("registry.json");
19333    let mut registry = ScanRegistry::load(&registry_path);
19334    let entry = build_run_registry_entry(run, run_id, &project_label, &artifacts);
19335    registry.add_entry(entry);
19336    registry
19337        .save(&registry_path)
19338        .with_context(|| format!("failed to write registry to {}", registry_path.display()))?;
19339
19340    Ok(run_dir)
19341}
19342
19343/// Render a static offline result-page dashboard and write it as `index.html` at
19344/// the root of the run output directory so business users can open it from disk.
19345#[allow(clippy::too_many_arguments)]
19346#[allow(clippy::too_many_lines)]
19347#[allow(clippy::similar_names)]
19348fn generate_offline_index(
19349    run: &sloc_core::AnalysisRun,
19350    run_dir: &Path,
19351    file_stem: &str,
19352    html_path: Option<&Path>,
19353    pdf_path: Option<&Path>,
19354    json_path: Option<&Path>,
19355    scan_config_path: Option<&Path>,
19356    result_context: &RunResultContext,
19357) {
19358    let Ok(run_dir) = sloc_core::reject_traversal(run_dir) else {
19359        return;
19360    };
19361    let run_dir = run_dir.as_path();
19362    let prev_entry = &result_context.prev_entry;
19363    let prev_scan_count = result_context.prev_scan_count;
19364    let project_path = &result_context.project_path;
19365
19366    let scan_delta = prev_entry.as_ref().and_then(|prev| {
19367        prev.json_path
19368            .as_ref()
19369            .and_then(|p| read_json(p).ok())
19370            .map(|prev_run| compute_delta(&prev_run, run))
19371    });
19372
19373    let files_analyzed = run.per_file_records.len() as u64;
19374    let files_skipped = run.skipped_file_records.len() as u64;
19375    let totals = sum_lang_totals(run);
19376
19377    let DeltaFields {
19378        prev_fa_str,
19379        prev_fs_str,
19380        prev_pl_str,
19381        prev_cl_str,
19382        prev_cml_str,
19383        prev_bl_str,
19384        delta_fa_str,
19385        delta_fa_class,
19386        delta_fs_str,
19387        delta_fs_class,
19388        delta_pl_str,
19389        delta_pl_class,
19390        delta_cl_str,
19391        delta_cl_class,
19392        delta_cml_str,
19393        delta_cml_class,
19394        delta_bl_str,
19395        delta_bl_class,
19396        delta_lines_added,
19397        delta_lines_removed,
19398        delta_lines_net_str,
19399        delta_lines_net_class,
19400    } = compute_delta_fields(
19401        prev_entry.as_ref(),
19402        &totals,
19403        files_analyzed,
19404        files_skipped,
19405        scan_delta.as_ref(),
19406    );
19407
19408    let git_commit_url = git_commit_url_for(run);
19409    let git_branch_url = git_branch_url_for(run);
19410    let scan_performed_by = scan_performed_by(run);
19411
19412    // Convert absolute path to relative from run_dir (for file:// navigation).
19413    let make_rel = |p: Option<&Path>| -> Option<String> {
19414        p.and_then(|abs| abs.strip_prefix(run_dir).ok())
19415            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
19416    };
19417
19418    let run_id = &run.tool.run_id;
19419
19420    // Submodule rows with relative paths into submodules/.
19421    let submodule_rows: Vec<SubmoduleRow> = run
19422        .submodule_summaries
19423        .iter()
19424        .map(|s| {
19425            let safe = sanitize_project_label(&s.name);
19426            let key = format!("sub_{safe}");
19427            let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
19428            SubmoduleRow {
19429                name: s.name.clone(),
19430                relative_path: s.relative_path.clone(),
19431                files_analyzed: s.files_analyzed,
19432                code_lines: s.code_lines,
19433                comment_lines: s.comment_lines,
19434                blank_lines: s.blank_lines,
19435                total_physical_lines: s.total_physical_lines,
19436                html_url: if sub_path.exists() {
19437                    Some(format!("submodules/{key}.html"))
19438                } else {
19439                    None
19440                },
19441            }
19442        })
19443        .collect();
19444
19445    let lang_chart_json = build_lang_chart_json(run);
19446
19447    let scan_config_rel =
19448        make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
19449
19450    let template = ResultTemplate {
19451        version: env!("CARGO_PKG_VERSION"),
19452        report_title: run.effective_configuration.reporting.report_title.clone(),
19453        project_path: project_path.clone(),
19454        output_dir: display_path(run_dir),
19455        run_id: run_id.clone(),
19456        run_id_short: run_id
19457            .split('-')
19458            .next_back()
19459            .unwrap_or(run_id)
19460            .chars()
19461            .take(7)
19462            .collect(),
19463        files_analyzed,
19464        files_skipped,
19465        physical_lines: totals.physical_lines,
19466        code_lines: totals.code_lines,
19467        comment_lines: totals.comment_lines,
19468        blank_lines: totals.blank_lines,
19469        mixed_lines: totals.mixed_lines,
19470        functions: totals.functions,
19471        classes: totals.classes,
19472        variables: totals.variables,
19473        imports: totals.imports,
19474        html_url: make_rel(html_path),
19475        pdf_url: make_rel(pdf_path),
19476        json_url: make_rel(json_path),
19477        html_download_url: make_rel(html_path),
19478        pdf_download_url: make_rel(pdf_path),
19479        json_download_url: make_rel(json_path),
19480        html_path: html_path.map(display_path),
19481        json_path: json_path.map(display_path),
19482        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
19483        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
19484        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
19485        prev_fa_str,
19486        prev_fs_str,
19487        prev_pl_str,
19488        prev_cl_str,
19489        prev_cml_str,
19490        prev_bl_str,
19491        delta_fa_str,
19492        delta_fa_class,
19493        delta_fs_str,
19494        delta_fs_class,
19495        delta_pl_str,
19496        delta_pl_class,
19497        delta_cl_str,
19498        delta_cl_class,
19499        delta_cml_str,
19500        delta_cml_class,
19501        delta_bl_str,
19502        delta_bl_class,
19503        delta_lines_added,
19504        delta_lines_removed,
19505        delta_lines_net_str,
19506        delta_lines_net_class,
19507        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
19508        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
19509        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
19510        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
19511        delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
19512        delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
19513        git_branch: run.git_branch.clone(),
19514        git_branch_url,
19515        git_commit: run.git_commit_short.clone(),
19516        git_commit_long: run.git_commit_long.clone(),
19517        git_author: run.git_commit_author.clone(),
19518        git_commit_url,
19519        scan_performed_by,
19520        scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
19521        scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
19522        os_display: format!(
19523            "{} / {}",
19524            run.environment.operating_system, run.environment.architecture
19525        ),
19526        test_count: run.summary_totals.test_count,
19527        test_assertion_count: run.summary_totals.test_assertion_count,
19528        current_scan_number: prev_scan_count + 1,
19529        prev_scan_count,
19530        submodule_rows,
19531        pdf_generating: false,
19532        scan_config_url: scan_config_rel,
19533        lang_chart_json,
19534        scatter_chart_json: build_scatter_chart_json(run),
19535        semantic_chart_json: build_semantic_chart_json(run),
19536        submodule_chart_json: build_submodule_chart_json(run),
19537        has_submodule_data: !run.submodule_summaries.is_empty(),
19538        has_semantic_data: run
19539            .totals_by_language
19540            .iter()
19541            .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
19542        csp_nonce: String::new(),
19543        confluence_configured: false,
19544        server_mode: false,
19545        report_header_footer: run
19546            .effective_configuration
19547            .reporting
19548            .report_header_footer
19549            .clone(),
19550        is_offline: true,
19551        cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
19552        lsloc: run.summary_totals.lsloc,
19553        uloc: run.uloc,
19554        dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
19555        duplicate_group_count: run.duplicate_groups.len(),
19556        has_cocomo: run.cocomo.is_some(),
19557        cocomo_effort_str: run
19558            .cocomo
19559            .as_ref()
19560            .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
19561        cocomo_duration_str: run
19562            .cocomo
19563            .as_ref()
19564            .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
19565        cocomo_staff_str: run
19566            .cocomo
19567            .as_ref()
19568            .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
19569        cocomo_ksloc_str: run
19570            .cocomo
19571            .as_ref()
19572            .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
19573        cocomo_mode_label: run.cocomo.as_ref().map_or_else(
19574            || "Organic".to_string(),
19575            |c| cocomo_mode_label(c.mode).to_string(),
19576        ),
19577        cocomo_mode_tooltip: run
19578            .cocomo
19579            .as_ref()
19580            .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
19581        complexity_alert: 0,
19582        has_coverage_data: run.summary_totals.coverage_lines_found > 0,
19583        cov_line_pct: cov_pct_str(
19584            run.summary_totals.coverage_lines_hit,
19585            run.summary_totals.coverage_lines_found,
19586        ),
19587        cov_fn_pct: cov_pct_str(
19588            run.summary_totals.coverage_functions_hit,
19589            run.summary_totals.coverage_functions_found,
19590        ),
19591        cov_branch_pct: cov_pct_str(
19592            run.summary_totals.coverage_branches_hit,
19593            run.summary_totals.coverage_branches_found,
19594        ),
19595        cov_lines_summary: cov_lines_summary_str(
19596            run.summary_totals.coverage_lines_hit,
19597            run.summary_totals.coverage_lines_found,
19598        ),
19599        // The offline mirror is a static file:// page with no server behind it, so the
19600        // server-backed merge panel is omitted — the run's auto-merged identities still show.
19601        ownership_html: String::new(),
19602    };
19603
19604    if let Ok(html) = template.render() {
19605        // Inline the brand + watermark logos as data URIs: a file:// page has no
19606        // server to resolve the /images/logo/* routes, so without this the top-left
19607        // logo and the repeated "Oxide" background watermark render as broken images.
19608        let html = inline_offline_logos(&html);
19609        let index_path = run_dir.join("index.html");
19610        if let Err(e) = fs::write(&index_path, html) {
19611            eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
19612        }
19613    }
19614}
19615
19616/// Rewrite the server-absolute logo image URLs to base64 data URIs so the static
19617/// offline `index.html` displays the brand logo and background watermark when
19618/// opened directly from disk (file://), where the `/images/...` routes do not exist.
19619fn inline_offline_logos(html: &str) -> String {
19620    use base64::Engine;
19621    let text_uri = format!(
19622        "data:image/png;base64,{}",
19623        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
19624    );
19625    let small_uri = format!(
19626        "data:image/png;base64,{}",
19627        base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
19628    );
19629    html.replace("/images/logo/logo-text.png", &text_uri)
19630        .replace("/images/logo/small-logo.png", &small_uri)
19631}
19632
19633/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
19634/// then root (old flat layout), for backwards compatibility.
19635fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
19636    // New layout: json/scan-config_*.json
19637    if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
19638        return Some(found);
19639    }
19640    // Old flat layout: scan-config.json or scan-config_*.json at root
19641    find_scan_config_in_dir_flat(dir)
19642}
19643
19644fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
19645    let exact = dir.join("scan-config.json");
19646    if exact.exists() {
19647        return Some(exact);
19648    }
19649    fs::read_dir(dir).ok().and_then(|entries| {
19650        entries
19651            .filter_map(std::result::Result::ok)
19652            .find(|e| {
19653                let name = e.file_name();
19654                let name = name.to_string_lossy();
19655                name.starts_with("scan-config") && name.ends_with(".json")
19656            })
19657            .map(|e| e.path())
19658    })
19659}
19660
19661// ── Config export / import ────────────────────────────────────────────────────
19662
19663/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
19664/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
19665#[derive(Deserialize)]
19666struct ExportPdfRequest {
19667    html: String,
19668    #[serde(default)]
19669    filename: Option<String>,
19670}
19671
19672async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
19673    let html_content = body.html;
19674    let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
19675    if html_content.is_empty() {
19676        return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
19677    }
19678    // Write HTML to a temp file, run headless Chrome PDF export, read result.
19679    let tmp_dir = std::env::temp_dir();
19680    let html_path = tmp_dir.join(format!(
19681        "sloc-export-{}.html",
19682        uuid::Uuid::new_v4().simple()
19683    ));
19684    let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
19685    if let Err(e) = std::fs::write(&html_path, &html_content) {
19686        return (
19687            StatusCode::INTERNAL_SERVER_ERROR,
19688            format!("Failed to write temp HTML: {e}"),
19689        )
19690            .into_response();
19691    }
19692    let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
19693    let _ = std::fs::remove_file(&html_path);
19694    if let Err(e) = pdf_result {
19695        let _ = std::fs::remove_file(&pdf_path);
19696        return (
19697            StatusCode::INTERNAL_SERVER_ERROR,
19698            format!("PDF generation failed: {e}"),
19699        )
19700            .into_response();
19701    }
19702    let pdf_bytes = match std::fs::read(&pdf_path) {
19703        Ok(b) => b,
19704        Err(e) => {
19705            let _ = std::fs::remove_file(&pdf_path);
19706            return (
19707                StatusCode::INTERNAL_SERVER_ERROR,
19708                format!("Failed to read PDF: {e}"),
19709            )
19710                .into_response();
19711        }
19712    };
19713    let _ = std::fs::remove_file(&pdf_path);
19714    let safe_name: String = filename
19715        .chars()
19716        .map(|c| {
19717            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
19718                c
19719            } else {
19720                '_'
19721            }
19722        })
19723        .collect();
19724    let disposition = format!("attachment; filename=\"{safe_name}\"");
19725    (
19726        [
19727            (header::CONTENT_TYPE, "application/pdf".to_string()),
19728            (header::CONTENT_DISPOSITION, disposition),
19729        ],
19730        pdf_bytes,
19731    )
19732        .into_response()
19733}
19734
19735async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
19736    let toml_str = match toml::to_string_pretty(&state.base_config) {
19737        Ok(s) => s,
19738        Err(e) => {
19739            return (
19740                StatusCode::INTERNAL_SERVER_ERROR,
19741                format!("serialization error: {e}"),
19742            )
19743                .into_response();
19744        }
19745    };
19746    (
19747        [
19748            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
19749            (
19750                header::CONTENT_DISPOSITION,
19751                "attachment; filename=\".oxide-sloc.toml\"",
19752            ),
19753        ],
19754        toml_str,
19755    )
19756        .into_response()
19757}
19758
19759#[derive(Serialize)]
19760struct OkResponse {
19761    ok: bool,
19762}
19763
19764#[derive(Serialize)]
19765struct SaveProfileResponse {
19766    ok: bool,
19767    id: String,
19768}
19769
19770#[derive(Serialize)]
19771struct ProfileListResponse {
19772    profiles: Vec<ScanProfile>,
19773}
19774
19775#[derive(Serialize)]
19776struct ImportConfigResponse {
19777    ok: bool,
19778    config: sloc_config::AppConfig,
19779}
19780
19781#[derive(Deserialize)]
19782struct ImportConfigBody {
19783    toml: String,
19784}
19785
19786async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
19787    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
19788        Ok(config) => {
19789            if let Err(e) = config.validate() {
19790                return error::unprocessable_entity(&e.to_string());
19791            }
19792            Json(ImportConfigResponse { ok: true, config }).into_response()
19793        }
19794        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
19795    }
19796}
19797
19798// ── Scan profiles API ─────────────────────────────────────────────────────────
19799
19800async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
19801    let store = state.scan_profiles.lock().await;
19802    Json(ProfileListResponse {
19803        profiles: store.profiles.clone(),
19804    })
19805}
19806
19807#[derive(Deserialize)]
19808struct SaveScanProfileBody {
19809    name: String,
19810    params: serde_json::Value,
19811}
19812
19813async fn api_save_scan_profile(
19814    State(state): State<AppState>,
19815    Json(body): Json<SaveScanProfileBody>,
19816) -> impl IntoResponse {
19817    if body.name.trim().is_empty() {
19818        return error::bad_request("name must not be empty");
19819    }
19820
19821    let id = uuid::Uuid::new_v4().to_string();
19822    let profile = ScanProfile {
19823        id: id.clone(),
19824        name: body.name.trim().to_string(),
19825        created_at: chrono::Utc::now().to_rfc3339(),
19826        params: body.params,
19827    };
19828
19829    let mut store = state.scan_profiles.lock().await;
19830    store.profiles.push(profile);
19831    if let Err(e) = store.save(&state.scan_profiles_path) {
19832        tracing::warn!("failed to persist scan profiles: {e}");
19833    }
19834    drop(store);
19835
19836    (
19837        StatusCode::CREATED,
19838        Json(SaveProfileResponse { ok: true, id }),
19839    )
19840        .into_response()
19841}
19842
19843async fn api_delete_scan_profile(
19844    State(state): State<AppState>,
19845    AxumPath(id): AxumPath<String>,
19846) -> impl IntoResponse {
19847    let mut store = state.scan_profiles.lock().await;
19848    let before = store.profiles.len();
19849    store.profiles.retain(|p| p.id != id);
19850    if store.profiles.len() == before {
19851        drop(store);
19852        return error::not_found("profile not found");
19853    }
19854    if let Err(e) = store.save(&state.scan_profiles_path) {
19855        tracing::warn!("failed to persist scan profiles: {e}");
19856    }
19857    drop(store);
19858    Json(OkResponse { ok: true }).into_response()
19859}
19860
19861fn resolve_output_root(raw: Option<&str>) -> PathBuf {
19862    let value = raw.unwrap_or("out/web").trim();
19863    let path = if value.is_empty() {
19864        PathBuf::from("out/web")
19865    } else {
19866        PathBuf::from(value)
19867    };
19868
19869    if path.is_absolute() {
19870        path
19871    } else {
19872        workspace_root().join(path)
19873    }
19874}
19875
19876/// Derive the directory that holds remote-repo clones from the output root.
19877fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
19878    std::env::var("SLOC_GIT_CLONES_DIR")
19879        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
19880}
19881
19882/// Build a deterministic filesystem path for a cloned remote repository.
19883/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
19884pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
19885    let safe: String = repo_url
19886        .chars()
19887        .map(|c| {
19888            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
19889                c
19890            } else {
19891                '_'
19892            }
19893        })
19894        .take(80)
19895        .collect();
19896    clones_dir.join(safe)
19897}
19898
19899/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
19900/// Runs synchronously — call from `tokio::task::spawn_blocking`.
19901pub(crate) fn scan_path_to_artifacts(
19902    scan_path: &Path,
19903    base_config: &AppConfig,
19904    label: &str,
19905) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
19906    let mut config = base_config.clone();
19907    config.discovery.root_paths = vec![scan_path.to_path_buf()];
19908    label.clone_into(&mut config.reporting.report_title);
19909    let run = analyze(&config, "git", None, None)?;
19910    let html = render_html(&run)?;
19911    let run_id = run.tool.run_id.clone();
19912    let project_label = sanitize_project_label(label);
19913    let output_dir = resolve_output_root(None).join(derive_run_dir_name(
19914        &project_label,
19915        run.git_branch.as_deref(),
19916        &run_id,
19917    ));
19918    let file_stem = {
19919        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
19920        if commit.is_empty() {
19921            project_label
19922        } else {
19923            format!("{project_label}_{commit}")
19924        }
19925    };
19926    let (artifacts, _pending_pdf) = persist_run_artifacts(
19927        &run,
19928        &html,
19929        &output_dir,
19930        label,
19931        &file_stem,
19932        RunResultContext::default(),
19933    )?;
19934    Ok((run_id, artifacts, run))
19935}
19936
19937/// Re-spawn background poll tasks for any polling schedules saved to disk.
19938async fn restart_poll_schedules(state: &AppState) {
19939    let store = state.schedules.lock().await;
19940    let poll_schedules: Vec<_> = store
19941        .schedules
19942        .iter()
19943        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
19944        .cloned()
19945        .collect();
19946    drop(store);
19947    for schedule in poll_schedules {
19948        let interval = schedule.interval_secs.unwrap_or(300);
19949        let st = state.clone();
19950        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
19951    }
19952}
19953
19954/// Warn at startup when GitLab webhook schedules exist but native TLS is not
19955/// enabled. GitLab authenticates webhooks with a plaintext `X-Gitlab-Token`
19956/// header (no HMAC over the body), so the token is exposed in cleartext unless
19957/// the transport is encrypted. This is only an advisory — TLS may be terminated
19958/// by an upstream reverse proxy, in which case the warning can be ignored.
19959async fn warn_insecure_gitlab_webhooks(state: &AppState) {
19960    if state.tls_enabled {
19961        return;
19962    }
19963    let store = state.schedules.lock().await;
19964    let has_gitlab_webhook = store.schedules.iter().any(|s| {
19965        s.kind == sloc_git::ScanScheduleKind::Webhook
19966            && s.provider == sloc_git::ScanScheduleProvider::GitLab
19967    });
19968    drop(store);
19969    if has_gitlab_webhook {
19970        tracing::warn!(
19971            "GitLab webhook schedule(s) configured but native TLS is not enabled. \
19972             GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
19973             terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
19974             proxy so the token is not exposed in cleartext."
19975        );
19976    }
19977}
19978
19979fn split_patterns(raw: Option<&str>) -> Vec<String> {
19980    raw.unwrap_or("")
19981        .lines()
19982        .flat_map(|line| line.split(','))
19983        .map(str::trim)
19984        .filter(|part| !part.is_empty())
19985        .map(ToOwned::to_owned)
19986        .collect()
19987}
19988
19989#[must_use]
19990pub fn build_sub_run(
19991    parent: &AnalysisRun,
19992    sub: &sloc_core::SubmoduleSummary,
19993    parent_path: &str,
19994) -> AnalysisRun {
19995    let mut sub_files: Vec<_> = parent
19996        .per_file_records
19997        .iter()
19998        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
19999        .cloned()
20000        .collect();
20001    let mut config = parent.effective_configuration.clone();
20002    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
20003
20004    // Aggregate semantic metrics that SubmoduleSummary doesn't store.
20005    let mut functions = 0u64;
20006    let mut classes = 0u64;
20007    let mut variables = 0u64;
20008    let mut imports = 0u64;
20009    let mut test_count = 0u64;
20010    let mut test_assertion_count = 0u64;
20011    let mut test_suite_count = 0u64;
20012    let mut mixed_lines_separate = 0u64;
20013    let mut coverage_lines_found = 0u64;
20014    let mut coverage_lines_hit = 0u64;
20015    let mut coverage_functions_found = 0u64;
20016    let mut coverage_functions_hit = 0u64;
20017    let mut coverage_branches_found = 0u64;
20018    let mut coverage_branches_hit = 0u64;
20019    for r in &sub_files {
20020        functions += r.raw_line_categories.functions;
20021        classes += r.raw_line_categories.classes;
20022        variables += r.raw_line_categories.variables;
20023        imports += r.raw_line_categories.imports;
20024        test_count += r.raw_line_categories.test_count;
20025        test_assertion_count += r.raw_line_categories.test_assertion_count;
20026        test_suite_count += r.raw_line_categories.test_suite_count;
20027        mixed_lines_separate += r.effective_counts.mixed_lines_separate;
20028        if let Some(cov) = &r.coverage {
20029            coverage_lines_found += u64::from(cov.lines_found);
20030            coverage_lines_hit += u64::from(cov.lines_hit);
20031            coverage_functions_found += u64::from(cov.functions_found);
20032            coverage_functions_hit += u64::from(cov.functions_hit);
20033            coverage_branches_found += u64::from(cov.branches_found);
20034            coverage_branches_hit += u64::from(cov.branches_hit);
20035        }
20036    }
20037
20038    // Rebuild a submodule-scoped author roll-up from the per-file blame data carried over from the
20039    // parent, remapping author ids in place so the sub-report's Code Ownership section resolves.
20040    // Without this the child inherits ownership indices into an empty author list and the whole
20041    // git-attribution section silently vanishes from every submodule report.
20042    let authors = sloc_core::scope_authors_to_records(&parent.authors, &mut sub_files);
20043
20044    AnalysisRun {
20045        tool: parent.tool.clone(),
20046        environment: parent.environment.clone(),
20047        effective_configuration: config,
20048        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
20049        summary_totals: SummaryTotals {
20050            files_considered: sub.files_analyzed,
20051            files_analyzed: sub.files_analyzed,
20052            files_skipped: 0,
20053            total_physical_lines: sub.total_physical_lines,
20054            code_lines: sub.code_lines,
20055            comment_lines: sub.comment_lines,
20056            blank_lines: sub.blank_lines,
20057            mixed_lines_separate,
20058            functions,
20059            classes,
20060            variables,
20061            imports,
20062            test_count,
20063            test_assertion_count,
20064            test_suite_count,
20065            coverage_lines_found,
20066            coverage_lines_hit,
20067            coverage_functions_found,
20068            coverage_functions_hit,
20069            coverage_branches_found,
20070            coverage_branches_hit,
20071            cyclomatic_complexity: 0,
20072            lsloc: None,
20073            ..Default::default()
20074        },
20075        totals_by_language: sub.language_summaries.clone(),
20076        per_file_records: sub_files,
20077        skipped_file_records: vec![],
20078        warnings: vec![],
20079        submodule_summaries: vec![],
20080        git_commit_short: sub.git_commit_short.clone(),
20081        git_commit_long: sub.git_commit_long.clone(),
20082        git_branch: sub.git_branch.clone(),
20083        git_commit_author: sub.git_commit_author.clone(),
20084        git_commit_date: sub.git_commit_date.clone(),
20085        git_tags: None,
20086        git_nearest_tag: None,
20087        git_remote_url: sub.git_remote_url.clone(),
20088        style_summary: None,
20089        cocomo: None,
20090        uloc: 0,
20091        dryness_pct: None,
20092        duplicate_groups: vec![],
20093        duplicates_excluded: 0,
20094        authors,
20095    }
20096}
20097
20098#[must_use]
20099pub fn sanitize_project_label(raw: &str) -> String {
20100    // Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
20101    // where `Path` treats '\' as a literal character, not a separator.
20102    let candidate = raw
20103        .split(['/', '\\'])
20104        .rfind(|s| !s.is_empty())
20105        .unwrap_or("project");
20106
20107    let mut value = String::with_capacity(candidate.len());
20108    for ch in candidate.chars() {
20109        if ch.is_ascii_alphanumeric() {
20110            value.push(ch.to_ascii_lowercase());
20111        } else {
20112            value.push('-');
20113        }
20114    }
20115
20116    let compact = value.trim_matches('-').to_string();
20117    if compact.is_empty() {
20118        "project".to_string()
20119    } else {
20120        compact
20121    }
20122}
20123
20124/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
20125/// comparisons with non-canonicalized stored paths work correctly.
20126fn strip_unc_prefix(path: PathBuf) -> PathBuf {
20127    let s = path.to_string_lossy();
20128    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
20129        return PathBuf::from(format!(r"\\{rest}"));
20130    }
20131    if let Some(rest) = s.strip_prefix(r"\\?\") {
20132        return PathBuf::from(rest);
20133    }
20134    path
20135}
20136
20137/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
20138/// commit page URL for the most common hosting platforms.
20139fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
20140    let base = if let Some(rest) = remote.strip_prefix("git@") {
20141        let (host, path) = rest.split_once(':')?;
20142        format!("https://{}/{}", host, path.trim_end_matches(".git"))
20143    } else if remote.starts_with("https://") || remote.starts_with("http://") {
20144        remote
20145            .trim_end_matches('/')
20146            .trim_end_matches(".git")
20147            .to_owned()
20148    } else {
20149        return None;
20150    };
20151    let base = base.trim_end_matches('/');
20152    // GitLab uses /-/commit/; everything else uses /commit/
20153    if base.contains("gitlab.com") || base.contains("gitlab.") {
20154        Some(format!("{base}/-/commit/{sha}"))
20155    } else if base.contains("bitbucket.org") {
20156        Some(format!("{base}/commits/{sha}"))
20157    } else {
20158        Some(format!("{base}/commit/{sha}"))
20159    }
20160}
20161
20162/// Convert a git remote URL (https or git@) + branch name into a browser-openable
20163/// branch page URL for the most common hosting platforms.
20164fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
20165    let base = if let Some(rest) = remote.strip_prefix("git@") {
20166        let (host, path) = rest.split_once(':')?;
20167        format!("https://{}/{}", host, path.trim_end_matches(".git"))
20168    } else if remote.starts_with("https://") || remote.starts_with("http://") {
20169        remote
20170            .trim_end_matches('/')
20171            .trim_end_matches(".git")
20172            .to_owned()
20173    } else {
20174        return None;
20175    };
20176    let base = base.trim_end_matches('/');
20177    if base.contains("gitlab.com") || base.contains("gitlab.") {
20178        Some(format!("{base}/-/tree/{branch}"))
20179    } else {
20180        Some(format!("{base}/tree/{branch}"))
20181    }
20182}
20183
20184fn display_path(path: &Path) -> String {
20185    let s = path.to_string_lossy();
20186    // Strip Windows extended-length prefix for display only; the underlying
20187    // PathBuf remains unchanged so file operations are unaffected.
20188    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
20189    // \\?\C:\path           →  C:\path          (local drive)
20190    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
20191        return format!(r"\\{rest}");
20192    }
20193    if let Some(rest) = s.strip_prefix(r"\\?\") {
20194        return rest.to_owned();
20195    }
20196    s.into_owned()
20197}
20198
20199fn sanitize_path_str(s: &str) -> String {
20200    // Forward-slash variants of the Windows extended-length prefix that appear
20201    // when paths stored as plain strings have been processed through some path
20202    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
20203    if let Some(rest) = s.strip_prefix("//?/UNC/") {
20204        return format!("//{rest}");
20205    }
20206    if let Some(rest) = s.strip_prefix("//?/") {
20207        return rest.to_owned();
20208    }
20209    display_path(Path::new(s))
20210}
20211
20212fn workspace_root() -> PathBuf {
20213    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
20214    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
20215        let p = PathBuf::from(root);
20216        if p.is_dir() {
20217            return p;
20218        }
20219    }
20220
20221    // Current working directory — works for `cargo run` from the project root
20222    // and for scripts/run.sh which cds there first.
20223    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
20224}
20225
20226/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
20227fn make_git_label(repo: &str, ref_name: &str) -> String {
20228    if repo.is_empty() || ref_name.is_empty() {
20229        return String::new();
20230    }
20231    let base = repo
20232        .trim_end_matches('/')
20233        .trim_end_matches(".git")
20234        .rsplit('/')
20235        .next()
20236        .unwrap_or("repo");
20237    let ref_safe: String = ref_name
20238        .chars()
20239        .map(|c| {
20240            if c.is_alphanumeric() || c == '-' || c == '.' {
20241                c
20242            } else {
20243                '_'
20244            }
20245        })
20246        .collect();
20247    format!("{base}_at_{ref_safe}_sloc")
20248}
20249
20250/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
20251fn desktop_dir() -> PathBuf {
20252    if let Ok(profile) = std::env::var("USERPROFILE") {
20253        let p = PathBuf::from(profile).join("Desktop");
20254        if p.exists() {
20255            return p;
20256        }
20257    }
20258    if let Ok(home) = std::env::var("HOME") {
20259        let p = PathBuf::from(home).join("Desktop");
20260        if p.exists() {
20261            return p;
20262        }
20263    }
20264    workspace_root().join("out").join("web")
20265}
20266
20267fn resolve_input_path(raw: &str) -> PathBuf {
20268    let trimmed = raw.trim();
20269    if trimmed.is_empty() {
20270        return workspace_root().join("samples").join("basic");
20271    }
20272
20273    let candidate = PathBuf::from(trimmed);
20274    let resolved = if candidate.is_absolute() {
20275        candidate
20276    } else {
20277        let rooted = workspace_root().join(&candidate);
20278        if rooted.exists() {
20279            rooted
20280        } else {
20281            workspace_root().join(candidate)
20282        }
20283    };
20284
20285    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
20286    // strip that prefix so stored paths and the displayed "Project path" are clean.
20287    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
20288    PathBuf::from(display_path(&canonical))
20289}
20290
20291/// Recursively sum the byte size of every regular file under `path`. Best-effort:
20292/// unreadable entries count as zero. Symlinks are NOT followed, so a symlink loop
20293/// cannot hang the walk and a link into a large tree cannot inflate the total — this
20294/// matters because the walk runs over client-uploaded/scanned trees. Iterative to
20295/// avoid stack blow-up on deeply nested inputs.
20296fn dir_size_bytes(path: &Path) -> u64 {
20297    let mut total = 0u64;
20298    let mut stack = vec![path.to_path_buf()];
20299    while let Some(dir) = stack.pop() {
20300        let Ok(rd) = fs::read_dir(&dir) else { continue };
20301        for entry in rd.filter_map(Result::ok) {
20302            let Ok(ft) = entry.file_type() else { continue };
20303            if ft.is_symlink() {
20304                continue;
20305            }
20306            if ft.is_dir() {
20307                stack.push(entry.path());
20308            } else if let Ok(meta) = entry.metadata() {
20309                total = total.saturating_add(meta.len());
20310            }
20311        }
20312    }
20313    total
20314}
20315
20316#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
20317fn format_dir_size(bytes: u64) -> String {
20318    if bytes >= 1_073_741_824 {
20319        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
20320    } else if bytes >= 1_048_576 {
20321        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
20322    } else if bytes >= 1_024 {
20323        format!("{:.0} KB", bytes as f64 / 1_024.0)
20324    } else {
20325        format!("{bytes} B")
20326    }
20327}
20328
20329fn render_submodule_chips(
20330    root: &Path,
20331    submodules: &[(String, std::path::PathBuf)],
20332    out: &mut String,
20333) {
20334    use std::fmt::Write as _;
20335    let count = submodules.len();
20336    out.push_str(r#"<div class="submodule-preview-strip">"#);
20337    write!(
20338        out,
20339        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>"#,
20340        if count == 1 { "" } else { "s" }
20341    )
20342    .ok();
20343    out.push_str(r#"<div class="submodule-preview-chips">"#);
20344    for (sub_name, sub_rel_path) in submodules {
20345        let sub_abs = root.join(sub_rel_path);
20346        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
20347        let mut sub_stats = PreviewStats::default();
20348        let mut sub_rows: Vec<PreviewRow> = Vec::new();
20349        let mut sub_langs: Vec<&'static str> = Vec::new();
20350        let mut sub_budget = PreviewBudget {
20351            shown: 0,
20352            max_entries: 2000,
20353            max_depth: 9,
20354        };
20355        let mut sub_next_id = 1usize;
20356        let _ = collect_preview_rows(
20357            &sub_abs,
20358            &sub_abs,
20359            0,
20360            None,
20361            &mut sub_next_id,
20362            &mut sub_budget,
20363            &mut sub_stats,
20364            &mut sub_rows,
20365            &mut sub_langs,
20366            &[],
20367            &[],
20368        );
20369        let stats_json = format!(
20370            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
20371            sub_stats.directories,
20372            sub_stats.files,
20373            sub_stats.supported,
20374            sub_stats.skipped,
20375            sub_stats.unsupported
20376        );
20377        write!(
20378            out,
20379            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>"#,
20380            escape_html(sub_name),
20381            escape_html(&sub_rel_path.to_string_lossy()),
20382            escape_html(&sub_size),
20383            escape_html(&stats_json),
20384            escape_html(sub_name),
20385            escape_html(&sub_size),
20386        )
20387        .ok();
20388    }
20389    out.push_str(
20390        r#"</div><button type="button" class="submodule-base-repo-btn sx-6aa34d74" >&#8593; Base repo</button>"#,
20391    );
20392    out.push_str(r"</div>");
20393}
20394
20395/// Amber caution banner shown when the selected folder spans multiple independent
20396/// git repositories. Each repo is a one-click button that re-selects it as the
20397/// scan root; a checkbox gates advancing past step 1 (wired up in front-end JS).
20398fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
20399    use std::fmt::Write as _;
20400    const MAX_LISTED: usize = 5;
20401    let total = layout.nested_repos.len();
20402
20403    out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
20404    if layout.root_is_repo {
20405        write!(
20406            out,
20407            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>",
20408            if total == 1 { "repository" } else { "repositories" }
20409        )
20410        .ok();
20411    } else {
20412        write!(
20413            out,
20414            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>"
20415        )
20416        .ok();
20417    }
20418
20419    out.push_str(r#"<div class="repo-pick-row">"#);
20420    for rel in layout.nested_repos.iter().take(MAX_LISTED) {
20421        let abs = root.join(rel);
20422        let abs_display = display_path(&abs);
20423        let label = rel.to_string_lossy().replace('\\', "/");
20424        write!(
20425            out,
20426            r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
20427            escape_html(&abs_display),
20428            escape_html(&label)
20429        )
20430        .ok();
20431    }
20432    if total > MAX_LISTED {
20433        write!(
20434            out,
20435            r#"<span class="repo-pick-more">and {} more</span>"#,
20436            total - MAX_LISTED
20437        )
20438        .ok();
20439    }
20440    out.push_str(r"</div>");
20441
20442    out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
20443    out.push_str(r"</div>");
20444}
20445
20446fn render_language_pills_row(languages: &[&str], out: &mut String) {
20447    use std::fmt::Write as _;
20448    if languages.is_empty() {
20449        out.push_str(
20450            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
20451        );
20452        return;
20453    }
20454    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
20455    for language in languages {
20456        if let Some(icon) = language_icon_file(language) {
20457            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();
20458        } else if let Some(svg) = language_inline_svg(language) {
20459            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();
20460        } else {
20461            write!(
20462                out,
20463                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
20464                escape_html(&language.to_ascii_lowercase()),
20465                escape_html(language)
20466            )
20467            .ok();
20468        }
20469    }
20470}
20471
20472#[allow(clippy::too_many_lines)]
20473fn build_preview_html(
20474    root: &Path,
20475    include_patterns: &[String],
20476    exclude_patterns: &[String],
20477) -> Result<String> {
20478    if !root.exists() {
20479        return Ok(format!(
20480            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
20481            escape_html(&display_path(root))
20482        ));
20483    }
20484
20485    let _selected = display_path(root);
20486    let mut stats = PreviewStats::default();
20487    let mut rows = Vec::new();
20488    let mut languages = Vec::new();
20489    let mut budget = PreviewBudget {
20490        shown: 0,
20491        max_entries: 600,
20492        max_depth: 9,
20493    };
20494    let mut next_row_id = 1usize;
20495
20496    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
20497        || root.to_string_lossy().into_owned(),
20498        std::string::ToString::to_string,
20499    );
20500    let root_modified = root
20501        .metadata()
20502        .ok()
20503        .and_then(|meta| meta.modified().ok())
20504        .map_or_else(|| "-".to_string(), format_system_time);
20505
20506    rows.push(PreviewRow {
20507        row_id: 0,
20508        parent_row_id: None,
20509        depth: 0,
20510        name: format!("{root_name}/"),
20511        kind: PreviewKind::Dir,
20512        is_dir: true,
20513        language: None,
20514        modified: root_modified,
20515        type_label: "Directory".to_string(),
20516    });
20517    collect_preview_rows(
20518        root,
20519        root,
20520        0,
20521        Some(0),
20522        &mut next_row_id,
20523        &mut budget,
20524        &mut stats,
20525        &mut rows,
20526        &mut languages,
20527        include_patterns,
20528        exclude_patterns,
20529    )?;
20530
20531    let root_size = format_dir_size(dir_size_bytes(root));
20532
20533    let mut out = String::new();
20534    write!(
20535        out,
20536        r#"<div class="explorer-wrap" data-project-size="{}">"#,
20537        escape_html(&root_size)
20538    )
20539    .ok();
20540    out.push_str(r#"<div class="explorer-toolbar compact">"#);
20541    out.push_str(r#"<div class="explorer-title-group">"#);
20542    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
20543    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
20544    out.push_str(r"</div></div>");
20545
20546    out.push_str(r#"<div class="scope-stats">"#);
20547    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();
20548    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();
20549    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();
20550    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();
20551    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();
20552    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>"#);
20553    out.push_str(r"</div>");
20554
20555    let submodules = sloc_core::detect_submodules(root);
20556    if !submodules.is_empty() {
20557        render_submodule_chips(root, &submodules, &mut out);
20558    }
20559
20560    let repo_layout = sloc_core::detect_repository_layout(root);
20561    if repo_layout.has_multiple_repos() {
20562        render_multi_repo_warning(root, &repo_layout, &mut out);
20563    }
20564
20565    out.push_str(r#"<div class="scope-info-row">"#);
20566    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
20567    render_language_pills_row(&languages, &mut out);
20568    out.push_str(r"</div></div>");
20569    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>"#);
20570    out.push_str(r"</div>");
20571
20572    out.push_str(r#"<div class="file-explorer-shell">"#);
20573    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>"#);
20574    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>"#);
20575    out.push_str(r#"<div class="file-explorer-tree">"#);
20576    for row in rows {
20577        let status_label = row.kind.label();
20578        let lang_attr = row.language.unwrap_or("");
20579        let toggle_html = if row.is_dir {
20580            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
20581                .to_string()
20582        } else {
20583            r#"<span class="tree-bullet">•</span>"#.to_string()
20584        };
20585        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" data-sx-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();
20586    }
20587    if budget.shown >= budget.max_entries {
20588        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 sx-a008a1ed" ><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>"#);
20589    }
20590    out.push_str(r"</div></div></div>");
20591
20592    Ok(out)
20593}
20594
20595#[derive(Default)]
20596struct PreviewStats {
20597    directories: usize,
20598    files: usize,
20599    supported: usize,
20600    skipped: usize,
20601    unsupported: usize,
20602}
20603
20604struct PreviewRow {
20605    row_id: usize,
20606    parent_row_id: Option<usize>,
20607    depth: usize,
20608    name: String,
20609    kind: PreviewKind,
20610    is_dir: bool,
20611    language: Option<&'static str>,
20612    modified: String,
20613    type_label: String,
20614}
20615
20616#[derive(Copy, Clone)]
20617enum PreviewKind {
20618    Dir,
20619    Supported,
20620    Skipped,
20621    Unsupported,
20622}
20623
20624impl PreviewKind {
20625    const fn filter_key(self) -> &'static str {
20626        match self {
20627            Self::Dir => "dir",
20628            Self::Supported => "supported",
20629            Self::Skipped => "skipped",
20630            Self::Unsupported => "unsupported",
20631        }
20632    }
20633
20634    const fn label(self) -> &'static str {
20635        match self {
20636            Self::Dir => "dir",
20637            Self::Supported => "supported",
20638            Self::Skipped => "skipped by policy",
20639            Self::Unsupported => "unsupported",
20640        }
20641    }
20642
20643    const fn badge_class(self) -> &'static str {
20644        match self {
20645            Self::Dir => "badge badge-dir",
20646            Self::Supported => "badge badge-scan",
20647            Self::Skipped => "badge badge-skip",
20648            Self::Unsupported => "badge badge-unsupported",
20649        }
20650    }
20651
20652    const fn node_class(self) -> &'static str {
20653        match self {
20654            Self::Dir => "tree-node-dir",
20655            Self::Supported => "tree-node-supported",
20656            Self::Skipped => "tree-node-skipped",
20657            Self::Unsupported => "tree-node-unsupported",
20658        }
20659    }
20660}
20661
20662struct PreviewBudget {
20663    shown: usize,
20664    max_entries: usize,
20665    max_depth: usize,
20666}
20667
20668/// Handle a single directory entry inside `collect_preview_rows`.
20669/// Returns `true` when the entry was handled (caller should `continue`).
20670#[allow(clippy::too_many_arguments)]
20671fn handle_preview_dir_entry(
20672    root: &Path,
20673    path: &Path,
20674    name: &str,
20675    modified: String,
20676    depth: usize,
20677    parent_row_id: Option<usize>,
20678    row_id: usize,
20679    next_row_id: &mut usize,
20680    budget: &mut PreviewBudget,
20681    stats: &mut PreviewStats,
20682    rows: &mut Vec<PreviewRow>,
20683    languages: &mut Vec<&'static str>,
20684    include_patterns: &[String],
20685    exclude_patterns: &[String],
20686) -> Result<()> {
20687    let relative = preview_relative_path(root, path);
20688    if should_skip_preview_directory(&relative, exclude_patterns) {
20689        return Ok(());
20690    }
20691    stats.directories += 1;
20692    rows.push(PreviewRow {
20693        row_id,
20694        parent_row_id,
20695        depth: depth + 1,
20696        name: format!("{name}/"),
20697        kind: PreviewKind::Dir,
20698        is_dir: true,
20699        language: None,
20700        modified,
20701        type_label: "Directory".to_string(),
20702    });
20703    budget.shown += 1;
20704    if !matches!(name, ".git" | "node_modules" | "target") {
20705        collect_preview_rows(
20706            root,
20707            path,
20708            depth + 1,
20709            Some(row_id),
20710            next_row_id,
20711            budget,
20712            stats,
20713            rows,
20714            languages,
20715            include_patterns,
20716            exclude_patterns,
20717        )?;
20718    }
20719    Ok(())
20720}
20721
20722/// Handle a single file entry inside `collect_preview_rows`.
20723#[allow(clippy::too_many_arguments)]
20724fn handle_preview_file_entry(
20725    root: &Path,
20726    path: &Path,
20727    name: &str,
20728    modified: String,
20729    depth: usize,
20730    parent_row_id: Option<usize>,
20731    row_id: usize,
20732    budget: &mut PreviewBudget,
20733    stats: &mut PreviewStats,
20734    rows: &mut Vec<PreviewRow>,
20735    languages: &mut Vec<&'static str>,
20736    include_patterns: &[String],
20737    exclude_patterns: &[String],
20738) {
20739    let relative = preview_relative_path(root, path);
20740    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
20741        return;
20742    }
20743    stats.files += 1;
20744    let kind = classify_preview_file(name);
20745    match kind {
20746        PreviewKind::Supported => stats.supported += 1,
20747        PreviewKind::Skipped => stats.skipped += 1,
20748        PreviewKind::Unsupported => stats.unsupported += 1,
20749        PreviewKind::Dir => {}
20750    }
20751    let language = detect_language_name(name);
20752    if let Some(lang) = language
20753        && !languages.contains(&lang)
20754    {
20755        languages.push(lang);
20756    }
20757    rows.push(PreviewRow {
20758        row_id,
20759        parent_row_id,
20760        depth: depth + 1,
20761        name: name.to_owned(),
20762        kind,
20763        is_dir: false,
20764        language,
20765        modified,
20766        type_label: preview_type_label(name, language, kind),
20767    });
20768    budget.shown += 1;
20769}
20770
20771#[allow(clippy::too_many_arguments)]
20772#[allow(clippy::too_many_lines)]
20773fn collect_preview_rows(
20774    root: &Path,
20775    dir: &Path,
20776    depth: usize,
20777    parent_row_id: Option<usize>,
20778    next_row_id: &mut usize,
20779    budget: &mut PreviewBudget,
20780    stats: &mut PreviewStats,
20781    rows: &mut Vec<PreviewRow>,
20782    languages: &mut Vec<&'static str>,
20783    include_patterns: &[String],
20784    exclude_patterns: &[String],
20785) -> Result<()> {
20786    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
20787        return Ok(());
20788    }
20789
20790    let mut entries = fs::read_dir(dir)
20791        .with_context(|| format!("failed to read directory {}", dir.display()))?
20792        .filter_map(std::result::Result::ok)
20793        .collect::<Vec<_>>();
20794    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
20795
20796    for entry in entries {
20797        if budget.shown >= budget.max_entries {
20798            break;
20799        }
20800
20801        let path = entry.path();
20802        let name = entry.file_name().to_string_lossy().into_owned();
20803        let Ok(metadata) = entry.metadata() else {
20804            continue;
20805        };
20806        let row_id = *next_row_id;
20807        *next_row_id += 1;
20808        let modified = metadata
20809            .modified()
20810            .ok()
20811            .map_or_else(|| "-".to_string(), format_system_time);
20812
20813        if metadata.is_dir() {
20814            handle_preview_dir_entry(
20815                root,
20816                &path,
20817                &name,
20818                modified,
20819                depth,
20820                parent_row_id,
20821                row_id,
20822                next_row_id,
20823                budget,
20824                stats,
20825                rows,
20826                languages,
20827                include_patterns,
20828                exclude_patterns,
20829            )?;
20830            continue;
20831        }
20832
20833        if metadata.is_file() {
20834            handle_preview_file_entry(
20835                root,
20836                &path,
20837                &name,
20838                modified,
20839                depth,
20840                parent_row_id,
20841                row_id,
20842                budget,
20843                stats,
20844                rows,
20845                languages,
20846                include_patterns,
20847                exclude_patterns,
20848            );
20849        }
20850    }
20851
20852    Ok(())
20853}
20854
20855fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
20856    if let Some(language) = language {
20857        return format!("{language} source");
20858    }
20859    let lower = name.to_ascii_lowercase();
20860    let ext = Path::new(&lower)
20861        .extension()
20862        .and_then(|e| e.to_str())
20863        .unwrap_or("");
20864    match kind {
20865        PreviewKind::Skipped => {
20866            if lower.ends_with(".min.js") {
20867                "Minified asset".to_string()
20868            } else if [
20869                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
20870            ]
20871            .contains(&ext)
20872            {
20873                "Binary or archive".to_string()
20874            } else {
20875                "Skipped file".to_string()
20876            }
20877        }
20878        PreviewKind::Unsupported => {
20879            if ext.is_empty() {
20880                "Unsupported file".to_string()
20881            } else {
20882                format!("{} file", ext.to_ascii_uppercase())
20883            }
20884        }
20885        PreviewKind::Supported => "Supported source".to_string(),
20886        PreviewKind::Dir => "Directory".to_string(),
20887    }
20888}
20889
20890fn format_system_time(time: SystemTime) -> String {
20891    #[allow(clippy::cast_possible_wrap)]
20892    let secs = match time.duration_since(UNIX_EPOCH) {
20893        Ok(duration) => duration.as_secs() as i64,
20894        Err(_) => return "-".to_string(),
20895    };
20896    let days = secs.div_euclid(86_400);
20897    let secs_of_day = secs.rem_euclid(86_400);
20898    let (year, month, day) = civil_from_days(days);
20899    let hour = secs_of_day / 3_600;
20900    let minute = (secs_of_day % 3_600) / 60;
20901    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
20902}
20903
20904#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
20905fn civil_from_days(days: i64) -> (i32, u32, u32) {
20906    let z = days + 719_468;
20907    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
20908    let doe = z - era * 146_097;
20909    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
20910    let y = yoe + era * 400;
20911    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
20912    let mp = (5 * doy + 2) / 153;
20913    let d = doy - (153 * mp + 2) / 5 + 1;
20914    let m = mp + if mp < 10 { 3 } else { -9 };
20915    let year = y + i64::from(m <= 2);
20916    (year as i32, m as u32, d as u32)
20917}
20918
20919// The input is already lowercased via `to_ascii_lowercase()` before calling
20920// `ends_with`, so the comparisons are inherently case-insensitive.
20921#[allow(clippy::case_sensitive_file_extension_comparisons)]
20922fn detect_language_name(name: &str) -> Option<&'static str> {
20923    let lower = name.to_ascii_lowercase();
20924    if lower.ends_with(".c") || lower.ends_with(".h") {
20925        Some("C")
20926    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
20927        .iter()
20928        .any(|s| lower.ends_with(s))
20929    {
20930        Some("C++")
20931    } else if lower.ends_with(".cs") {
20932        Some("C#")
20933    } else if lower.ends_with(".py") {
20934        Some("Python")
20935    } else if lower.ends_with(".sh") {
20936        Some("Shell")
20937    } else if [".ps1", ".psm1", ".psd1"]
20938        .iter()
20939        .any(|s| lower.ends_with(s))
20940    {
20941        Some("PowerShell")
20942    } else {
20943        None
20944    }
20945}
20946
20947fn language_icon_file(language: &str) -> Option<&'static str> {
20948    match language {
20949        "C" => Some("c.png"),
20950        "C++" => Some("cpp.png"),
20951        "C#" => Some("c-sharp.png"),
20952        "Python" => Some("python.png"),
20953        "Shell" => Some("shell.png"),
20954        "PowerShell" => Some("powershell.png"),
20955        "JavaScript" => Some("java-script.png"),
20956        "HTML" => Some("html-5.png"),
20957        "Java" => Some("java.png"),
20958        "Visual Basic" => Some("visual-basic.png"),
20959        "Assembly" => Some("asm.png"),
20960        "Go" => Some("go.png"),
20961        "R" => Some("r.png"),
20962        "XML" => Some("xml.png"),
20963        "Groovy" => Some("groovy.png"),
20964        "Dockerfile" => Some("docker.png"),
20965        "Makefile" => Some("makefile.svg"),
20966        "Perl" => Some("perl.svg"),
20967        _ => None,
20968    }
20969}
20970
20971// Inline SVG badges for languages that have no PNG icon in images/icons/.
20972// Using inline SVG keeps the web UI fully self-contained — no extra files
20973// needed on disk, no 404s on air-gapped deployments.
20974// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
20975fn language_inline_svg(language: &str) -> Option<&'static str> {
20976    match language {
20977        "Rust" => Some(
20978            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>"##,
20979        ),
20980        "TypeScript" => Some(
20981            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>"##,
20982        ),
20983        _ => None,
20984    }
20985}
20986
20987// The input is already lowercased via `to_ascii_lowercase()` before the
20988// `ends_with` calls, so these comparisons are inherently case-insensitive.
20989#[allow(clippy::case_sensitive_file_extension_comparisons)]
20990fn classify_preview_file(name: &str) -> PreviewKind {
20991    let lower = name.to_ascii_lowercase();
20992
20993    let scannable = [
20994        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
20995        ".psm1", ".psd1",
20996    ]
20997    .iter()
20998    .any(|suffix| lower.ends_with(suffix));
20999
21000    if scannable {
21001        PreviewKind::Supported
21002    } else if lower.ends_with(".min.js")
21003        || lower.ends_with(".lock")
21004        || lower.ends_with(".png")
21005        || lower.ends_with(".jpg")
21006        || lower.ends_with(".jpeg")
21007        || lower.ends_with(".gif")
21008        || lower.ends_with(".zip")
21009        || lower.ends_with(".pdf")
21010        || lower.ends_with(".pyc")
21011        || lower.ends_with(".xz")
21012        || lower.ends_with(".tar")
21013        || lower.ends_with(".gz")
21014    {
21015        PreviewKind::Skipped
21016    } else {
21017        PreviewKind::Unsupported
21018    }
21019}
21020
21021fn preview_relative_path(root: &Path, path: &Path) -> String {
21022    path.strip_prefix(root)
21023        .ok()
21024        .unwrap_or(path)
21025        .to_string_lossy()
21026        .replace('\\', "/")
21027        .trim_matches('/')
21028        .to_string()
21029}
21030
21031fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
21032    if relative.is_empty() {
21033        return false;
21034    }
21035
21036    exclude_patterns.iter().any(|pattern| {
21037        wildcard_match(pattern, relative)
21038            || wildcard_match(pattern, &format!("{relative}/"))
21039            || wildcard_match(pattern, &format!("{relative}/placeholder"))
21040    })
21041}
21042
21043fn should_include_preview_file(
21044    relative: &str,
21045    include_patterns: &[String],
21046    exclude_patterns: &[String],
21047) -> bool {
21048    if relative.is_empty() {
21049        return true;
21050    }
21051
21052    let included = include_patterns.is_empty()
21053        || include_patterns
21054            .iter()
21055            .any(|pattern| wildcard_match(pattern, relative));
21056    let excluded = exclude_patterns
21057        .iter()
21058        .any(|pattern| wildcard_match(pattern, relative));
21059
21060    included && !excluded
21061}
21062
21063fn wildcard_match(pattern: &str, candidate: &str) -> bool {
21064    let pattern = pattern.trim().replace('\\', "/");
21065    let candidate = candidate.trim().replace('\\', "/");
21066    let p = pattern.as_bytes();
21067    let c = candidate.as_bytes();
21068    let mut pi = 0usize;
21069    let mut ci = 0usize;
21070    let mut star: Option<usize> = None;
21071    let mut star_match = 0usize;
21072
21073    while ci < c.len() {
21074        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
21075            pi += 1;
21076            ci += 1;
21077        } else if pi < p.len() && p[pi] == b'*' {
21078            while pi < p.len() && p[pi] == b'*' {
21079                pi += 1;
21080            }
21081            star = Some(pi);
21082            star_match = ci;
21083        } else if let Some(star_pi) = star {
21084            star_match += 1;
21085            ci = star_match;
21086            pi = star_pi;
21087        } else {
21088            return false;
21089        }
21090    }
21091
21092    while pi < p.len() && p[pi] == b'*' {
21093        pi += 1;
21094    }
21095
21096    pi == p.len()
21097}
21098
21099fn escape_html(value: &str) -> String {
21100    value
21101        .replace('&', "&amp;")
21102        .replace('<', "&lt;")
21103        .replace('>', "&gt;")
21104        .replace('"', "&quot;")
21105        .replace('\'', "&#39;")
21106}
21107
21108#[derive(Clone)]
21109struct SubmoduleRow {
21110    name: String,
21111    relative_path: String,
21112    files_analyzed: u64,
21113    code_lines: u64,
21114    comment_lines: u64,
21115    blank_lines: u64,
21116    total_physical_lines: u64,
21117    html_url: Option<String>,
21118}
21119
21120#[derive(Template)]
21121#[template(
21122    source = r##"
21123<!doctype html>
21124<html lang="en">
21125<head>
21126  <meta charset="utf-8">
21127  <title>OxideSLOC | tmp-sloc</title>
21128  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21129  <link rel="stylesheet" href="/static/app.css">
21130  <script src="/static/app.js"></script>
21131  <style nonce="{{ csp_nonce }}">
21132    :root {
21133      --bg: #efe9e2;
21134      --surface: #fcfaf7;
21135      --surface-2: #f7f0e8;
21136      --surface-3: #efe3d5;
21137      --line: #dfcfbf;
21138      --line-strong: #cfb29c;
21139      --text: #2f241c;
21140      --muted: #6f6257;
21141      --muted-2: #917f71;
21142      --nav: #b85d33;
21143      --nav-2: #7a371b;
21144      --accent: #2563eb;
21145      --accent-2: #1d4ed8;
21146      --oxide: #b85d33;
21147      --oxide-2: #8f4220;
21148      --success-bg: #eaf9ee;
21149      --success-text: #1c8746;
21150      --warn-bg: #fff2d8;
21151      --warn-text: #926000;
21152      --danger-bg: #fdeaea;
21153      --danger-text: #b33b3b;
21154      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
21155      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
21156      --radius: 14px;
21157    }
21158
21159    body.dark-theme {
21160      --bg: #1b1511;
21161      --surface: #261c17;
21162      --surface-2: #2d221d;
21163      --surface-3: #372922;
21164      --line: #524238;
21165      --line-strong: #6c5649;
21166      --text: #f5ece6;
21167      --muted: #c7b7aa;
21168      --muted-2: #aa9485;
21169      --nav: #b85d33;
21170      --nav-2: #7a371b;
21171      --accent: #6f9bff;
21172      --accent-2: #4a78ee;
21173      --oxide: #d37a4c;
21174      --oxide-2: #b35428;
21175      --success-bg: #163927;
21176      --success-text: #8fe2a8;
21177      --warn-bg: #3c2d11;
21178      --warn-text: #f3cb75;
21179      --danger-bg: #3d1f1f;
21180      --danger-text: #ff9f9f;
21181      --shadow: 0 14px 28px rgba(0,0,0,0.28);
21182      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
21183    }
21184
21185    * { box-sizing: border-box; }
21186    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); }
21187    html { overflow-y: scroll; }
21188    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
21189    .top-nav, .page, .loading { position: relative; z-index: 2; }
21190    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
21191    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
21192    .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); }
21193    .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; }
21194    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
21195    .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)); }
21196    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
21197    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
21198    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
21199    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
21200    .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; }
21201    .nav-project-pill.visible { display:inline-flex; }
21202    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
21203    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
21204    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
21205    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21206    @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; } }
21207    .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; }
21208    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
21209    .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; }
21210    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
21211    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
21212    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
21213    .theme-toggle .icon-sun { display:none; }
21214    body.dark-theme .theme-toggle .icon-sun { display:block; }
21215    body.dark-theme .theme-toggle .icon-moon { display:none; }
21216    .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;}
21217    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21218    .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);}
21219    .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;}
21220    .settings-close:hover{color:var(--text);background:var(--surface-2);}
21221    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21222    .settings-modal-body{padding:14px 16px 16px;}
21223    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21224    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21225    .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;}
21226    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21227    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21228    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21229    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21230    .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;}
21231    .tz-select:focus{border-color:var(--oxide);}
21232    .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; }
21233    .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;}
21234    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
21235    @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
21236    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
21237    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
21238    .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; }
21239    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
21240    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
21241    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
21242    .wb-stats-header { padding: 10px 24px 0; }
21243    .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
21244    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
21245    .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; }
21246    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
21247    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
21248    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
21249    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
21250    .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; }
21251    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
21252    .ws-stat-analyzers { position: relative; }
21253    .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; }
21254    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
21255    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
21256    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
21257    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
21258    .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; }
21259    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
21260    .ws-divider { display: none; }
21261    .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%; }
21262    .ws-path-link:hover { color:var(--oxide); }
21263    body.dark-theme .ws-path-link { color:var(--oxide); }
21264    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
21265    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
21266    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
21267    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
21268    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
21269    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
21270    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
21271    .ws-mini-box-lg { flex:2 1 0; }
21272    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
21273    .ws-mini-box-br { flex:1.5 1 0; }
21274    .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; }
21275    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
21276    .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
21277    #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; }
21278    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
21279    .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; }
21280    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
21281    .git-source-banner strong { font-weight:800; color:var(--text); }
21282    .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; }
21283    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
21284    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
21285    .git-source-banner a:hover { text-decoration:underline; }
21286    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
21287    .path-scope-sep { background:var(--line); margin:4px 14px; }
21288    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
21289    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
21290    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
21291    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
21292    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
21293    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
21294    .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; }
21295    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
21296    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
21297    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
21298    .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; }
21299    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
21300    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
21301    [data-wb-tip] { cursor:help; }
21302    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
21303    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
21304    .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; }
21305    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
21306    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
21307    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
21308    .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; }
21309    .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); }
21310    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
21311    .side-info-card { padding: 18px; }
21312    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
21313    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
21314    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
21315    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
21316    .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); }
21317    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
21318    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
21319    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
21320    .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; }
21321    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
21322    .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; }
21323    .side-stack::-webkit-scrollbar { display: none; }
21324    .step-nav { padding: 20px 16px; }
21325    .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
21326    .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; }
21327    .step-button:hover { background: var(--surface-2); }
21328    .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); }
21329    .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; }
21330    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
21331    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
21332    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
21333    .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); }
21334    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
21335    .step-nav-sum-row:last-child { border-bottom:none; }
21336    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
21337    .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; }
21338    .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
21339    .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
21340    .quick-scan-section { padding: 10px 4px 14px; }
21341    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
21342    .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; }
21343    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
21344    .quick-scan-btn:active { transform:translateY(0); }
21345    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
21346    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
21347    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
21348    @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);} }
21349    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
21350    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
21351    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
21352    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
21353    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
21354    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
21355    .step-button.done .step-check { opacity:1; }
21356    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
21357    .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; }
21358    .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; }
21359    .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
21360    .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; }
21361    .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
21362    .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
21363    .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; }
21364    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
21365    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
21366    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
21367    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
21368    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
21369    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
21370    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
21371    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
21372    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
21373    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
21374    .card-body { padding: 22px; }
21375    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
21376    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
21377    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
21378    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
21379    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
21380    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
21381    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
21382    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
21383    .field { min-width:0; }
21384    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
21385    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; }
21386    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); }
21387    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
21388    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); }
21389    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
21390    textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
21391    .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
21392    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
21393    .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; }
21394    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
21395    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
21396    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
21397    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
21398    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
21399    .input-group.compact { grid-template-columns: 1fr auto auto; }
21400    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
21401    .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)); }
21402    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
21403    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
21404    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
21405    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
21406    .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; }
21407    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
21408    .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; }
21409    .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); }
21410    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
21411    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
21412    #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
21413    #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
21414    .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
21415    .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
21416    @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; } }
21417    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
21418    button.secondary { background: var(--surface); }
21419    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
21420    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
21421    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
21422    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
21423    .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); }
21424    .section + .wizard-actions { border-top: none; padding-top: 0; }
21425    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
21426    .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; }
21427    .default-path-overlay.open { opacity: 1; pointer-events: auto; }
21428    .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; }
21429    .default-path-overlay.open .default-path-modal { transform: translateY(0); }
21430    .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
21431    .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
21432    .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
21433    .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); }
21434    body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
21435    .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
21436    .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
21437    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
21438    .field-help-grid.coupled-help { margin-top: 12px; }
21439    .field-help-grid.preset-grid { align-items: start; }
21440    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
21441    .preset-inline-row .field { margin: 0; }
21442    .preset-inline-row .explainer-card { margin: 0; }
21443    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
21444    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
21445    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
21446    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
21447    .preset-kv-row > :last-child { flex:1; min-width:0; }
21448    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
21449    .output-field-row .field { margin: 0; }
21450    .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; }
21451    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
21452    .step3-subtitle { margin-bottom: 10px; max-width: none; }
21453    .counting-intro { margin-bottom: 8px; max-width: none; }
21454    .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; }
21455    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
21456    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
21457    .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; }
21458    .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; }
21459    .section-spacer-top { margin-top: 28px; }
21460    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
21461    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
21462    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
21463    .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); }
21464    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
21465    .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; }
21466    .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; }
21467    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
21468    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
21469    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
21470    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
21471    .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
21472    .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; }
21473    .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
21474    .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); }
21475    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; }
21476    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; }
21477    .attrib-estimate { margin-top:8px; padding:8px 11px; border-radius:8px; font-size:12px; font-weight:600; line-height:1.45; border:1px solid transparent; }
21478    .attrib-estimate.est-light { background:rgba(42,104,70,0.09); border-color:rgba(42,104,70,0.22); color:#2a6846; }
21479    .attrib-estimate.est-moderate { background:rgba(212,160,23,0.1); border-color:rgba(212,160,23,0.28); color:#8a6a10; }
21480    .attrib-estimate.est-heavy { background:rgba(178,48,48,0.09); border-color:rgba(178,48,48,0.26); color:#a33030; }
21481    .attrib-estimate b { font-weight:800; }
21482    body.dark-theme .attrib-estimate.est-light { background:rgba(90,186,138,0.12); border-color:rgba(90,186,138,0.3); color:#5aba8a; }
21483    body.dark-theme .attrib-estimate.est-moderate { background:rgba(212,160,23,0.14); border-color:rgba(212,160,23,0.34); color:#e0c060; }
21484    body.dark-theme .attrib-estimate.est-heavy { background:rgba(224,112,112,0.14); border-color:rgba(224,112,112,0.34); color:#e07070; }
21485    .review-attrib-warn { margin-top:16px; padding:13px 16px; border-radius:10px; font-size:13px; font-weight:600; line-height:1.55; display:flex; gap:10px; align-items:flex-start; }
21486    .review-attrib-warn::before { content:"\26A0"; font-size:16px; line-height:1.3; flex:0 0 auto; }
21487    .review-attrib-warn.raw-moderate { background:rgba(212,160,23,0.12); border:1px solid rgba(212,160,23,0.3); color:#8a6a10; }
21488    .review-attrib-warn.raw-heavy { background:rgba(178,48,48,0.1); border:1px solid rgba(178,48,48,0.3); color:#a33030; }
21489    .review-attrib-warn b { font-weight:800; }
21490    body.dark-theme .review-attrib-warn.raw-moderate { background:rgba(212,160,23,0.16); border-color:rgba(212,160,23,0.36); color:#e0c060; }
21491    body.dark-theme .review-attrib-warn.raw-heavy { background:rgba(224,112,112,0.16); border-color:rgba(224,112,112,0.36); color:#e07070; }
21492    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
21493    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
21494    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
21495    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
21496    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
21497    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
21498    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
21499    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
21500    .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); }
21501    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
21502    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
21503    .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; }
21504    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
21505    .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; }
21506    .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; }
21507    .always-tracked-tip-body { flex:1; min-width:0; }
21508    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
21509    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
21510    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
21511    .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
21512    .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
21513    .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
21514    @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
21515    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
21516    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
21517    .advanced-rule-description strong { color: var(--text); }
21518    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
21519    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
21520    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
21521    .review-link:hover { text-decoration: underline; }
21522    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
21523    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
21524    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
21525    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
21526    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
21527    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
21528    .review-card ul { padding-left: 18px; margin: 0; }
21529    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
21530    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
21531    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
21532    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
21533    .review-card { min-height: 0; }
21534    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
21535    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
21536    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
21537    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
21538    .lang-overflow-chip { position:relative; cursor:default; }
21539    .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; }
21540    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
21541    .git-inline-row { align-items:start; }
21542    .mixed-line-card { display:flex; flex-direction:column; }
21543    .preset-inline-row .toggle-card { justify-content: center; }
21544        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
21545    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
21546    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
21547    .explorer-title { font-size: 18px; font-weight: 850; }
21548    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
21549    .explorer-subtitle.wide { max-width: none; }
21550    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
21551    .better-spacing { align-items:flex-start; justify-content:flex-end; }
21552    .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; }
21553    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
21554    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
21555    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
21556    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
21557    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
21558    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
21559    .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; }
21560    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
21561    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
21562    .scope-stat-button.supported { background: var(--success-bg); }
21563    .scope-stat-button.skipped { background: var(--warn-bg); }
21564    .scope-stat-button.unsupported { background: var(--danger-bg); }
21565    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
21566    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
21567    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
21568    [data-tooltip] { position: relative; }
21569    [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); }
21570    [data-tooltip]:hover::after { display: block; }
21571    .scope-stat-button[data-tooltip] { cursor: pointer; }
21572    .badge[data-tooltip] { cursor: help; }
21573    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
21574    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
21575    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
21576    .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; }
21577    .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; }
21578    code { display:inline-block; margin-top:0; padding:2px 7px; }
21579    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
21580    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
21581    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
21582    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
21583    .language-pill.muted-pill { color: var(--muted); }
21584    button.language-pill { appearance:none; cursor:pointer; }
21585    .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); }
21586    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
21587    .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; }
21588    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
21589    .file-explorer-search-row { margin-left: auto; }
21590    .explorer-filter-select { min-width: 170px; width: 170px; }
21591    .explorer-search { min-width: 300px; width: 300px; }
21592    .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); }
21593    .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; }
21594    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
21595    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
21596    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
21597    .file-explorer-tree { max-height: 640px; overflow:auto; }
21598    .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); }
21599    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
21600    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
21601    .tree-row.hidden-by-filter { display:none !important; }
21602    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
21603    .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; }
21604    .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; }
21605    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
21606    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
21607    .tree-node { display:inline-flex; align-items:center; min-width:0; }
21608    .tree-node-dir { color: var(--text); font-weight: 800; }
21609    .tree-node-supported { color: var(--success-text); }
21610    .tree-node-skipped { color: var(--warn-text); }
21611    .tree-node-unsupported { color: var(--danger-text); }
21612    .tree-node-more { color: var(--muted-2); font-style: italic; }
21613    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
21614    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
21615    .tree-status-cell { display:flex; justify-content:flex-start; }
21616    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
21617    .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; }
21618    .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
21619    .preview-warning p { margin: 0 0 10px; }
21620    .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
21621    .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; }
21622    .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
21623    .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
21624    .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
21625    .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
21626    .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; }
21627    .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
21628    .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; }
21629    @keyframes prevSpin { to { transform:rotate(360deg); } }
21630    .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
21631    .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; }
21632    .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; }
21633    .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
21634    .preview-gate-info svg { width:16px; height:16px; }
21635    .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
21636    @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); } }
21637    button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
21638    .preview-loading-text { flex:1; min-width:0; }
21639    .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
21640    .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
21641    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
21642    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
21643    .cov-scan-idle { display:none; }
21644    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
21645    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
21646    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
21647    .cov-scan-title { font-weight:600; font-size:12.5px; }
21648    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
21649    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
21650    .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; }
21651    .cov-scan-use:hover { opacity:.75; }
21652    .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; }
21653    .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; }
21654    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
21655    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
21656    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
21657    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
21658    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
21659    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
21660    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
21661    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
21662    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
21663    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
21664    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
21665    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
21666    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
21667    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
21668    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
21669    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
21670    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
21671    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
21672    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
21673    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
21674    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
21675    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
21676    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
21677    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
21678    .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); }
21679    .loading.active { display:flex; }
21680    /* Lock page scroll while the analysis modal is open so the removed scrollbar
21681       gutter doesn't pull the centered card slightly left of true center. */
21682    body.modal-open { overflow: hidden; }
21683    .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; }
21684    /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
21685    .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; }
21686    .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
21687    .loading-card > * { position:relative; z-index:1; }
21688    @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
21689    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%); }
21690    .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
21691    .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; }
21692    @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
21693    .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
21694    .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
21695    .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; }
21696    .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
21697    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
21698    .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; }
21699    .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
21700    /* The Phase value holds long labels ("Attributing authorship", "Summarizing submodules") in a
21701       narrow card — let it wrap and shrink so the whole message stays readable instead of clipping. */
21702    #lc-phase { font-size:.8rem;line-height:1.2;white-space:normal;overflow:visible;text-overflow:clip;word-break:break-word; }
21703    .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; }
21704    .lc-steps { display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:2px 0;margin-bottom:18px; }
21705    .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; }
21706    .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
21707    .lc-step.done { color:var(--muted);opacity:0.55; }
21708    .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; }
21709    .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
21710    .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
21711    .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
21712    .lc-overall { margin-bottom:14px; }
21713    .lc-overall-head { display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px; }
21714    .lc-overall-label { font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em; }
21715    .lc-overall-pct { font-size:13px;font-weight:900;color:var(--oxide,#d37a4c);font-variant-numeric:tabular-nums; }
21716    .lc-overall-track { height:8px;border-radius:999px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden; }
21717    .lc-overall-fill { height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg,#d37a4c,#c45c10);transition:width .4s ease; }
21718    body.dark-theme .lc-overall-fill { background:linear-gradient(90deg,#e08a52,#d37a4c); }
21719    .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; }
21720    .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; }
21721    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
21722    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
21723    .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; }
21724    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
21725    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
21726    .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; }
21727    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
21728    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
21729    .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; }
21730    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
21731    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
21732    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
21733    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
21734    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
21735    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
21736    .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; }
21737    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
21738    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
21739    .hidden { display:none !important; }
21740    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21741    .site-footer a{color:var(--muted);}
21742    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
21743    @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; } }
21744    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
21745    @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));}}
21746    .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;}
21747    .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; }
21748    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
21749    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
21750    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
21751    .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; }
21752    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
21753    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
21754    .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; }
21755    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
21756    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
21757    .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; }
21758    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
21759    .path-info-row { display:flex; align-items:stretch; justify-content:flex-start; gap:0; margin-top:8px; border:1px solid var(--line); border-radius:10px; padding:0; overflow:hidden; flex-wrap:wrap; width:fit-content; max-width:100%; background:var(--surface); }
21760    .path-info-cell { display:flex; align-items:center; gap:7px; padding:8px 18px; border-left:1px solid var(--line); }
21761    .path-info-row > .path-info-cell:first-child, .path-info-row > *:first-child { border-left:none; }
21762    .path-info-cell.hidden, .commit-counts.hidden { display:none; }
21763    .pi-branch svg { width:15px; height:15px; flex:0 0 auto; opacity:.8; color:var(--muted); }
21764    .pi-branch-label { font-size:12px; font-weight:600; color:var(--muted); }
21765    .pi-branch-name { font-size:13px; font-weight:800; color:var(--oxide,#b85d33); }
21766    .commit-counts { display:flex; align-items:center; gap:20px; margin-left:0; padding:8px 18px; border-left:1px solid var(--line); }
21767    .commit-counts .cc-item { display:flex; flex-direction:column; line-height:1.15; }
21768    .commit-counts .cc-val { font-size:13px; font-weight:800; color:var(--oxide,#b85d33); font-variant-numeric:tabular-nums; }
21769    .commit-counts .cc-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; }
21770    .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; }
21771    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
21772    .info-icon-btn:hover { color:var(--text); }
21773    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); }
21774    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
21775    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
21776    .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;}
21777    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
21778    .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;}
21779    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
21780    #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);}
21781    #offline-file-banner.show{display:flex;}
21782    #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
21783    #offline-file-banner .ofb-text{flex:1;}
21784    #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
21785    #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;}
21786    #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;}
21787    #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
21788    body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
21789    body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
21790    body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
21791    body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
21792    body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
21793    body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
21794  </style>
21795</head>
21796<body id="page-top">
21797  <div id="offline-file-banner" role="alert">
21798    <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>
21799    <span class="ofb-text">
21800      Charts, images, and navigation require the oxide-sloc server.
21801      Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
21802      then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
21803      The metric tables below are fully readable without the server.
21804    </span>
21805    <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
21806  </div>
21807  <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>
21808  <div class="background-watermarks" aria-hidden="true">
21809    <img src="/images/logo/logo-text.png" alt="" />
21810    <img src="/images/logo/logo-text.png" alt="" />
21811    <img src="/images/logo/logo-text.png" alt="" />
21812    <img src="/images/logo/logo-text.png" alt="" />
21813    <img src="/images/logo/logo-text.png" alt="" />
21814    <img src="/images/logo/logo-text.png" alt="" />
21815    <img src="/images/logo/logo-text.png" alt="" />
21816    <img src="/images/logo/logo-text.png" alt="" />
21817    <img src="/images/logo/logo-text.png" alt="" />
21818    <img src="/images/logo/logo-text.png" alt="" />
21819    <img src="/images/logo/logo-text.png" alt="" />
21820    <img src="/images/logo/logo-text.png" alt="" />
21821    <img src="/images/logo/logo-text.png" alt="" />
21822    <img src="/images/logo/logo-text.png" alt="" />
21823  </div>
21824  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21825  <div class="top-nav">
21826    <div class="top-nav-inner">
21827      <a class="brand" href="/">
21828        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
21829        <div class="brand-copy">
21830          <div class="brand-title">OxideSLOC</div>
21831          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
21832        </div>
21833      </a>
21834      <div class="nav-project-slot">
21835        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
21836          <span class="nav-project-label">Project</span>
21837          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
21838        </div>
21839      </div>
21840      <div class="nav-status">
21841        <a class="nav-pill" href="/">Home</a>
21842        <div class="nav-dropdown">
21843          <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>
21844          <div class="nav-dropdown-menu">
21845            <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>
21846          </div>
21847        </div>
21848        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21849        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21850        <div class="nav-dropdown">
21851          <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>
21852          <div class="nav-dropdown-menu">
21853            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
21854            <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>
21855          </div>
21856        </div>
21857        <div class="server-status-wrap" id="server-status-wrap">
21858          <div class="nav-pill server-online-pill" id="server-status-pill">
21859            <span class="status-dot" id="status-dot"></span>
21860            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21861            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
21862          </div>
21863          <div class="server-status-tip">
21864            {% if server_mode %}
21865            OxideSLOC is running in server mode — accessible on your LAN.
21866            {% else %}
21867            OxideSLOC is running locally — only accessible from this machine.
21868            {% endif %}
21869            <span class="sx-238af6bc" id="server-tip-ping" ></span>
21870          </div>
21871        </div>
21872        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21873          <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>
21874        </button>
21875        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
21876          <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>
21877          <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>
21878        </button>
21879      </div>
21880    </div>
21881  </div>
21882
21883  <div class="loading" id="loading">
21884    <div class="loading-card" id="loading-card">
21885      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
21886      <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
21887      <div class="lc-path" id="lc-path"><svg class="sx-9e6dfe63" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" ><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>
21888      <div class="lc-steps" id="lc-steps">
21889        <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
21890        <div class="lc-step-arrow">›</div>
21891        <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
21892        <div class="lc-step-arrow">›</div>
21893        <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Attribute</div>
21894        <div class="lc-step-arrow">›</div>
21895        <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Report</div>
21896        <div class="lc-step-arrow">›</div>
21897        <div class="lc-step" id="lc-step-5"><span class="lc-step-num">5</span>Done</div>
21898      </div>
21899      <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
21900      <div class="lc-metrics" id="lc-metrics">
21901        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
21902        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
21903        <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>
21904        <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>
21905      </div>
21906      <div class="lc-overall" id="lc-overall">
21907        <div class="lc-overall-head">
21908          <span class="lc-overall-label">Overall progress</span>
21909          <span class="lc-overall-pct" id="lc-overall-pct">0%</span>
21910        </div>
21911        <div class="lc-overall-track"><div class="lc-overall-fill" id="lc-overall-fill"></div></div>
21912      </div>
21913      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
21914      <div class="lc-warn hidden" id="lc-warn">This is taking longer than usual. Large repositories — especially with submodules and per-author attribution on — can take several minutes. The analysis is still running.</div>
21915      <div class="lc-warn hidden" id="lc-attrib-note">Attributing authorship: running <code>git blame</code> on every source file across the repo and all submodules. This is the slowest stage and scales with file count — the counter above shows live progress. To skip it on future scans, set Code ownership to "Off" in the scan options.</div>
21916      <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>
21917      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
21918      <div class="lc-actions hidden" id="lc-actions">
21919        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
21920        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
21921      </div>
21922      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
21923        <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>
21924        Cancel scan
21925      </button>
21926    </div>
21927  </div>
21928
21929  <div class="page">
21930    <div class="workbench-strip">
21931      <div class="workbench-box wb-stats">
21932        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
21933          <span class="wb-stats-title">Analysis session</span>
21934        </div>
21935        <div class="ws-left">
21936          <div class="ws-stat ws-stat-analyzers">
21937            <span class="ws-label">Analyzers</span>
21938            <span class="ws-value">
21939              <span class="ws-badge">60 languages</span>
21940            </span>
21941            <div class="ws-lang-tooltip">
21942              <div class="ws-lang-tooltip-hdr">60 supported languages</div>
21943              <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>
21944              <div class="ws-lang-grid">
21945                <span class="ws-lang-item">Assembly</span>
21946                <span class="ws-lang-item">C</span>
21947                <span class="ws-lang-item">C++</span>
21948                <span class="ws-lang-item">C#</span>
21949                <span class="ws-lang-item">Clojure</span>
21950                <span class="ws-lang-item">CSS</span>
21951                <span class="ws-lang-item">Dart</span>
21952                <span class="ws-lang-item">Dockerfile</span>
21953                <span class="ws-lang-item">Elixir</span>
21954                <span class="ws-lang-item">Erlang</span>
21955                <span class="ws-lang-item">F#</span>
21956                <span class="ws-lang-item">Go</span>
21957                <span class="ws-lang-item">Groovy</span>
21958                <span class="ws-lang-item">Haskell</span>
21959                <span class="ws-lang-item">HTML</span>
21960                <span class="ws-lang-item">Java</span>
21961                <span class="ws-lang-item">JavaScript</span>
21962                <span class="ws-lang-item">Julia</span>
21963                <span class="ws-lang-item">Kotlin</span>
21964                <span class="ws-lang-item">Lua</span>
21965                <span class="ws-lang-item">Makefile</span>
21966                <span class="ws-lang-item">Nim</span>
21967                <span class="ws-lang-item">Obj-C</span>
21968                <span class="ws-lang-item">OCaml</span>
21969                <span class="ws-lang-item">Perl</span>
21970                <span class="ws-lang-item">PHP</span>
21971                <span class="ws-lang-item">PowerShell</span>
21972                <span class="ws-lang-item">Python</span>
21973                <span class="ws-lang-item">R</span>
21974                <span class="ws-lang-item">Ruby</span>
21975                <span class="ws-lang-item">Rust</span>
21976                <span class="ws-lang-item">Scala</span>
21977                <span class="ws-lang-item">SCSS</span>
21978                <span class="ws-lang-item">Shell</span>
21979                <span class="ws-lang-item">SQL</span>
21980                <span class="ws-lang-item">Svelte</span>
21981                <span class="ws-lang-item">Swift</span>
21982                <span class="ws-lang-item">TypeScript</span>
21983                <span class="ws-lang-item">Vue</span>
21984                <span class="ws-lang-item">XML</span>
21985                <span class="ws-lang-item">Zig</span>
21986                <span class="ws-lang-item">Solidity</span>
21987                <span class="ws-lang-item">Protobuf</span>
21988                <span class="ws-lang-item">HCL</span>
21989                <span class="ws-lang-item">GraphQL</span>
21990                <span class="ws-lang-item">Ada</span>
21991                <span class="ws-lang-item">VHDL</span>
21992                <span class="ws-lang-item">Verilog</span>
21993                <span class="ws-lang-item">Tcl</span>
21994                <span class="ws-lang-item">Pascal</span>
21995                <span class="ws-lang-item">Visual Basic</span>
21996                <span class="ws-lang-item">Lisp</span>
21997                <span class="ws-lang-item">Fortran</span>
21998                <span class="ws-lang-item">Nix</span>
21999                <span class="ws-lang-item">Crystal</span>
22000                <span class="ws-lang-item">D</span>
22001                <span class="ws-lang-item">GLSL</span>
22002                <span class="ws-lang-item">CMake</span>
22003                <span class="ws-lang-item">Elm</span>
22004                <span class="ws-lang-item">Awk</span>
22005              </div>
22006            </div>
22007          </div>
22008          <div class="ws-divider"></div>
22009          <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>
22010          <div class="ws-divider"></div>
22011          <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.">
22012            <span class="ws-label">Output</span>
22013            <span class="ws-value">
22014              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
22015                <span id="ws-output-root">project/sloc</span>
22016              </button>
22017            </span>
22018          </div>
22019        </div>
22020      </div>
22021      <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.">
22022        <div class="ws-history-label">Scan history</div>
22023        <div class="ws-history-inner">
22024          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
22025            <div class="ws-mini-label">Scans</div>
22026            <div class="ws-mini-value" id="ws-scan-count">—</div>
22027          </div>
22028          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
22029            <div class="ws-mini-label">Last Scan</div>
22030            <div class="ws-mini-value" id="ws-last-scan">—</div>
22031          </div>
22032          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
22033            <div class="ws-mini-label">Branch</div>
22034            <div class="ws-mini-value" id="ws-branch">—</div>
22035          </div>
22036        </div>
22037      </div>
22038    </div>
22039
22040    <div class="layout">
22041      <aside class="side-stack">
22042        <section class="step-nav">
22043        <h3>Guided scan setup</h3>
22044        <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
22045          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
22046          Top of page
22047        </a>
22048        <button type="button" class="step-button active sx-726e8b31"  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>
22049        <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>
22050        <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>
22051        <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>
22052
22053        <div class="step-steps-divider"></div>
22054
22055        <div class="step-nav-info" id="step-nav-info">
22056          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
22057          <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>
22058        </div>
22059
22060        <div class="step-nav-summary sx-6aa34d74" id="sidebar-summary" >
22061          <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>
22062          <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>
22063          <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>
22064        </div>
22065
22066        <div class="quick-scan-divider"></div>
22067        <div class="quick-scan-section">
22068          <div class="quick-scan-label">No customization needed?</div>
22069          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
22070            <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>
22071            Quick Scan
22072          </button>
22073          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
22074        </div>
22075
22076        <div class="sidebar-kbd-hint"><span class="sidebar-kbd-key">←</span><span>Back</span><span class="sx-2eebea0e" >·</span><span class="sidebar-kbd-key">→</span><span>Next</span></div>
22077        <div class="sidebar-scroll-divider"></div>
22078        <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
22079          <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
22080          Skip to bottom
22081        </a>
22082        </section>
22083
22084      </aside>
22085
22086      <section class="card">
22087        <div class="card-header">
22088          <div class="card-title-row">
22089            <div>
22090              <h1 class="card-title">Guided scan configuration</h1>
22091              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
22092            </div>
22093            <div class="wizard-progress" aria-label="Scan setup progress">
22094              <div class="wizard-progress-top">
22095                <span class="wizard-progress-label">Setup progress</span>
22096                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
22097              </div>
22098              <div class="wizard-progress-track">
22099                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
22100              </div>
22101            </div>
22102          </div>
22103        </div>
22104        <div class="card-body">
22105          <form method="post" action="/analyze" id="analyze-form">
22106            <div class="wizard-step active" data-step="1">
22107              <div class="section">
22108                <div class="section-kicker">Step 1</div>
22109                <h2>Select project and preview scope</h2>
22110                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
22111                <div class="field">
22112                  <label for="path">Project path</label>
22113                  {% if !git_repo.is_empty() %}
22114                  <div class="git-source-banner">
22115                    <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>
22116                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
22117                    <a href="/git-browser">← Back to Git Browser</a>
22118                  </div>
22119                  {% endif %}
22120                  <div class="path-scope-grid">
22121                      {% if !git_repo.is_empty() %}
22122                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input sx-14aa50c7" required  />
22123                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
22124                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
22125                      {% else %}
22126                      <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
22127                      <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
22128                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
22129                      {% endif %}
22130                    <div class="path-scope-sep"></div>
22131                    <div class="scope-legend-row">
22132                      <span class="scope-legend-label">Scope legend:</span>
22133                      <span class="scope-legend-badges">
22134                        <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
22135                        <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
22136                        <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
22137                      </span>
22138                    </div>
22139                  </div>
22140                  {% if git_repo.is_empty() %}
22141                  {% if server_mode %}
22142                  <div id="upload-limit-tip" class="hint sx-df83faee" >
22143                    ℹ️ Files are compressed and streamed — no fixed size limit.
22144                  </div>
22145                  {% endif %}
22146                  <div class="path-info-row" id="path-info-row">
22147                    <div class="path-info-cell">
22148                      <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
22149                        <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>
22150                        <span id="project-size-text">Project size: —</span>
22151                      </button>
22152                    </div>
22153                    <div class="path-info-cell pi-branch hidden" id="git-branch-box" title="Currently checked-out git branch">
22154                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
22155                      <span class="pi-branch-label">Git Branch:</span>
22156                      <span class="pi-branch-name" id="git-branch-name">—</span>
22157                    </div>
22158                    <div class="commit-counts hidden" id="commit-counts" title="Git commit history depth (HEAD)">
22159                      <div class="cc-item">
22160                        <span class="cc-val" id="cc-super">—</span>
22161                        <span class="cc-label">super-repo commits</span>
22162                      </div>
22163                      <div class="cc-item" id="cc-combined-item">
22164                        <span class="cc-val" id="cc-combined">—</span>
22165                        <span class="cc-label">with submodules</span>
22166                      </div>
22167                    </div>
22168                  </div>
22169                  {% else %}
22170                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
22171                  {% endif %}
22172                  <div id="path-history-badge" class="path-history-badge sx-6aa34d74" ></div>
22173                  <div id="zero-files-warning" class="path-history-badge warning sx-6aa34d74"  role="alert"></div>
22174                </div>
22175
22176                <div class="scope-preview-divider" aria-hidden="true"></div>
22177
22178                <div id="preview-panel">
22179                  <div class="preview-error">Loading preview...</div>
22180                </div>
22181              </div>
22182
22183              <div class="section sx-8d842990" >
22184                <div class="preset-inline-row git-inline-row">
22185                  <div class="toggle-card sx-38965f9b" >
22186                    <div class="field-help-title sx-fffdd52c" >Git integration</div>
22187                    <h4 class="sx-58629c9c" >Submodule breakdown</h4>
22188                    <label class="checkbox">
22189                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
22190                      <div>
22191                        <span>Detect and separate git submodules</span>
22192                        <div class="hint sx-36e81f86" >Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
22193                      </div>
22194                    </label>
22195                  </div>
22196                  <div class="explainer-card prominent sx-38965f9b" >
22197                    <div class="field-help-title sx-c500155b" >What this does</div>
22198                    <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.<br /><strong>Heads up:</strong> submodule working trees are scanned in full, so a repo with many large submodules (e.g. vendored dependencies) can hold tens of thousands of files. That makes the scan — and the per-author attribution pass in particular — take noticeably longer. The scan screen shows live progress for each stage.</div>
22199                    <div class="code-sample sx-726e8b31" >[submodule "libs/core"]
22200    path = libs/core
22201    url  = https://github.com/org/core.git
22202
22203[submodule "libs/ui"]
22204    path = libs/ui
22205    url  = https://github.com/org/ui.git</div>
22206                  </div>
22207                </div>
22208              </div>
22209
22210              <div class="section">
22211                <div class="field-grid">
22212                  <div class="field">
22213                    <div class="glob-label-row">
22214                      <label class="sx-e34c4670" for="include_globs" >Include globs <span class="lbl-opt">— optional</span></label>
22215                      <div id="include-scope-badge" class="include-scope-badge scope-all sx-bacc46ba" aria-live="polite" ><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>
22216                    </div>
22217                    <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>
22218                    <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>
22219                  </div>
22220                  <div class="field">
22221                    <div class="glob-label-row">
22222                      <label class="sx-e34c4670" for="exclude_globs" >Exclude globs</label>
22223                    </div>
22224                    <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
22225                    <div id="quick-exclude-chips" class="quick-excl-row">
22226                      <span class="quick-excl-label">Quick add:</span>
22227                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
22228                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
22229                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
22230                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
22231                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
22232                      <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>
22233                    </div>
22234                    <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>
22235                  </div>
22236                </div>
22237                <div class="glob-guidance-grid">
22238                  <div class="glob-guidance-card">
22239                    <strong>How to read them</strong>
22240                    <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>
22241                  </div>
22242                  <div class="glob-guidance-card">
22243                    <strong>Common include examples</strong>
22244                    <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>
22245                  </div>
22246                  <div class="glob-guidance-card">
22247                    <strong>Common exclude examples</strong>
22248                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
22249                  </div>
22250                </div>
22251              </div>
22252
22253              <div class="section sx-8d842990" >
22254                <div class="preset-inline-row git-inline-row">
22255                  <div class="toggle-card sx-38965f9b" >
22256                    <div class="field-help-title sx-fffdd52c" >Coverage</div>
22257                    <h4 class="sx-58629c9c" >Code Coverage file <span class="sx-76948ec7" >(optional)</span></h4>
22258                    <div class="field sx-38965f9b" >
22259                      <div class="input-group compact">
22260                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
22261                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
22262                      </div>
22263                      <div class="hint sx-a33b8fc2" >When provided, line, function, and branch coverage percentages are overlaid on each file in the report and shown on the Test Metrics page.</div>
22264                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
22265                    </div>
22266                  </div>
22267                  <div class="explainer-card prominent sx-38965f9b" >
22268                    <div class="field-help-title sx-c500155b" >What this does</div>
22269                    <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>
22270                    <div class="code-sample sx-161ac0cc" ># C / C++ — gcov + lcov (LCOV)
22271lcov --capture --directory . --output-file coverage/lcov.info
22272
22273# C / C++ — llvm-cov (LCOV)
22274llvm-profdata merge -sparse default.profraw -o default.profdata
22275llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
22276
22277# C# — coverlet (Cobertura XML)
22278dotnet test --collect:"XPlat Code Coverage"
22279
22280# Python — pytest-cov (Cobertura XML)
22281pytest --cov --cov-report=xml
22282
22283# Python — coverage.py native JSON
22284coverage run -m pytest && coverage json   # writes coverage.json
22285
22286# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
22287./gradlew jacocoTestReport</div>
22288                  </div>
22289                </div>
22290              </div>
22291
22292              <div class="wizard-actions">
22293                <div class="left"></div>
22294                <div class="right">
22295                  <div id="preview-gate-status" class="preview-gate-status sx-d0466aa3" aria-live="polite" >
22296                    <span class="preview-gate-spinner" aria-hidden="true"></span>
22297                    <span class="preview-gate-text">Scanning project scope&hellip;</span>
22298                    <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">
22299                      <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>
22300                    </button>
22301                  </div>
22302                  <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
22303                </div>
22304              </div>
22305            </div>
22306
22307            <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
22308              <div class="default-path-modal">
22309                <h3 id="default-path-title">
22310                  <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>
22311                  Proceed with the default sample test?
22312                </h3>
22313                <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
22314                <p>You haven&#39;t selected your own project yet.</p>
22315                <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
22316                <div class="default-path-actions">
22317                  <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
22318                  <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
22319                </div>
22320              </div>
22321            </div>
22322
22323            <div class="wizard-step" data-step="2">
22324              <div class="section">
22325                <div class="section-kicker">Step 2</div>
22326                <h2>Choose counting behavior</h2>
22327                <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>
22328<div class="subsection-bar">Primary line classification</div>
22329                <div class="preset-kv-row">
22330                  <div class="toggle-card mixed-line-card sx-38965f9b" >
22331                    <div class="field-help-title sx-fffdd52c" >Primary line classification</div>
22332                    <h4 class="sx-58629c9c" >Mixed-line policy</h4>
22333                    <select id="mixed_line_policy" name="mixed_line_policy">
22334                      <option value="code_only">Code only</option>
22335                      <option value="code_and_comment">Code and comment</option>
22336                      <option value="comment_only">Comment only</option>
22337                      <option value="separate_mixed_category">Separate mixed category</option>
22338                    </select>
22339                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
22340                  </div>
22341                  <div class="explainer-card prominent sx-38965f9b" >
22342                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
22343                    <div class="explainer-body" id="mixed-policy-description"></div>
22344                    <div class="code-sample" id="mixed-policy-example"></div>
22345                  </div>
22346                </div>
22347              </div>
22348
22349              <div class="subsection-bar">Additional scan rules</div>
22350              <div class="scan-rules-grid">
22351                <div class="preset-inline-row">
22352                  <div class="toggle-card sx-38965f9b" >
22353                    <div class="field-help-title">Generated files</div>
22354                    <h4 class="sx-0ace2b19" >Generated-file detection</h4>
22355                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
22356                  </div>
22357                  <div class="explainer-card prominent sx-38965f9b" >
22358                    <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>
22359                    <div class="code-sample sx-161ac0cc" ># generated_file_detection = "enabled"
22360# Files matching codegen patterns are excluded:
22361#   *.generated.cs  *.pb.go  *.g.dart</div>
22362                  </div>
22363                </div>
22364                <div class="preset-inline-row">
22365                  <div class="toggle-card sx-38965f9b" >
22366                    <div class="field-help-title">Minified files</div>
22367                    <h4 class="sx-0ace2b19" >Minified-file detection</h4>
22368                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
22369                  </div>
22370                  <div class="explainer-card prominent sx-38965f9b" >
22371                    <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>
22372                    <div class="code-sample sx-161ac0cc" ># minified_file_detection = "enabled"
22373# Heuristic: very long lines + low whitespace ratio
22374#   jquery.min.js  bundle.min.css  → skipped</div>
22375                  </div>
22376                </div>
22377                <div class="preset-inline-row">
22378                  <div class="toggle-card sx-38965f9b" >
22379                    <div class="field-help-title">Vendor directories</div>
22380                    <h4 class="sx-0ace2b19" >Vendor-directory detection</h4>
22381                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
22382                  </div>
22383                  <div class="explainer-card prominent sx-38965f9b" >
22384                    <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>
22385                    <div class="code-sample sx-161ac0cc" ># vendor_directory_detection = "enabled"
22386# Directories named vendor/ node_modules/ third_party/
22387#   → entire subtree is excluded from totals</div>
22388                  </div>
22389                </div>
22390                <div class="preset-inline-row">
22391                  <div class="toggle-card sx-38965f9b" >
22392                    <div class="field-help-title">Lockfiles and manifests</div>
22393                    <h4 class="sx-0ace2b19" >Include lockfiles</h4>
22394                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
22395                  </div>
22396                  <div class="explainer-card prominent sx-38965f9b" >
22397                    <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>
22398                    <div class="code-sample sx-161ac0cc" ># include_lockfiles = false  (default)
22399# Files like package-lock.json  Cargo.lock  yarn.lock
22400#   → skipped unless this is enabled</div>
22401                  </div>
22402                </div>
22403                <div class="preset-inline-row">
22404                  <div class="toggle-card sx-38965f9b" >
22405                    <div class="field-help-title">Binary handling</div>
22406                    <h4 class="sx-0ace2b19" >Binary file behavior</h4>
22407                    <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>
22408                  </div>
22409                  <div class="explainer-card prominent sx-38965f9b" >
22410                    <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>
22411                    <div class="code-sample sx-161ac0cc" ># binary_file_behavior = "skip"  (default)
22412# Detected via long lines + low whitespace heuristic
22413#   .png  .exe  .so  → skipped silently</div>
22414                  </div>
22415                </div>
22416                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
22417                  <div class="toggle-card sx-38965f9b" >
22418                    <div class="field-help-title">Python docstrings</div>
22419                    <h4 class="sx-0ace2b19" >Docstring counting</h4>
22420                    <label class="checkbox">
22421                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
22422                      <span>Count as comment-style lines</span>
22423                    </label>
22424                  </div>
22425                  <div class="explainer-card prominent sx-38965f9b" >
22426                    <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>
22427                    <div class="code-sample sx-dc0bca3d" id="python-docstring-example" ></div>
22428                  </div>
22429                </div>
22430              </div>
22431              <div class="subsection-bar">IEEE 1045-1992 counting</div>
22432              <div class="scan-rules-grid">
22433                <div class="preset-inline-row">
22434                  <div class="toggle-card sx-38965f9b" >
22435                    <div class="field-help-title">Continuation lines</div>
22436                    <h4 class="sx-0ace2b19" >Continuation-line policy</h4>
22437                    <select name="continuation_line_policy" id="continuation_line_policy">
22438                      <option value="each_physical_line" selected>Each physical line (default)</option>
22439                      <option value="collapse_to_logical">Collapse to logical line</option>
22440                    </select>
22441                  </div>
22442                  <div class="explainer-card prominent sx-38965f9b" >
22443                    <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>
22444                    <div class="code-sample sx-161ac0cc" >#define MAX(a, b) \
22445    ((a) &gt; (b) ? (a) : (b))
22446# each_physical_line → 2 SLOC
22447# collapse_to_logical → 1 SLOC</div>
22448                  </div>
22449                </div>
22450                <div class="preset-inline-row">
22451                  <div class="toggle-card sx-38965f9b" >
22452                    <div class="field-help-title">Block-comment blanks</div>
22453                    <h4 class="sx-0ace2b19" >Blank lines in block comments</h4>
22454                    <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
22455                      <option value="count_as_comment" selected>Count as comment (default)</option>
22456                      <option value="count_as_blank">Count as blank</option>
22457                    </select>
22458                  </div>
22459                  <div class="explainer-card prominent sx-38965f9b" >
22460                    <div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code class="sx-c42f23b8" >/* … */</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>
22461                    <div class="code-sample sx-161ac0cc" >/*
22462 * Summary line
22463 *              ← blank inside block comment
22464 * Detail line
22465 */
22466# count_as_comment → blank counts toward comments
22467# count_as_blank   → blank counts toward blanks</div>
22468                  </div>
22469                </div>
22470                <div class="preset-inline-row">
22471                  <div class="toggle-card sx-38965f9b" >
22472                    <div class="field-help-title">Compiler directives</div>
22473                    <h4 class="sx-0ace2b19" >Count compiler directives</h4>
22474                    <select name="count_compiler_directives" id="count_compiler_directives">
22475                      <option value="enabled" selected>Include in code SLOC (default)</option>
22476                      <option value="disabled">Exclude from code SLOC</option>
22477                    </select>
22478                  </div>
22479                  <div class="explainer-card prominent sx-38965f9b" >
22480                    <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 class="sx-c42f23b8" >#include</code> / <code class="sx-c42f23b8" >#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>
22481                    <div class="code-sample sx-161ac0cc" >#include &lt;stdio.h&gt;   ← compiler directive
22482#define BUF 256     ← compiler directive
22483int main() { … }   ← code
22484# enabled  → 3 code SLOC
22485# disabled → 1 code SLOC + 2 directive lines</div>
22486                  </div>
22487                </div>
22488              </div>
22489
22490              <div class="subsection-bar">Code Style Analysis</div>
22491              <div class="scan-rules-grid">
22492                <div class="preset-inline-row">
22493                  <div class="toggle-card sx-38965f9b" >
22494                    <div class="field-help-title">Style analysis</div>
22495                    <h4 class="sx-0ace2b19" >Enable style analysis</h4>
22496                    <select name="style_analysis_enabled" id="style_analysis_enabled">
22497                      <option value="enabled" selected>Enabled (default)</option>
22498                      <option value="disabled">Disabled — skip style scoring</option>
22499                    </select>
22500                  </div>
22501                  <div class="explainer-card prominent sx-38965f9b" >
22502                    <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>
22503                    <div class="code-sample sx-161ac0cc" ># style_analysis_enabled = true   (default)
22504# style_analysis_enabled = false  (skip, faster scan)
22505# Disabling removes the Code Style section from the report.</div>
22506                  </div>
22507                </div>
22508                <div class="preset-inline-row">
22509                  <div class="toggle-card sx-38965f9b" >
22510                    <div class="field-help-title">Column-width threshold</div>
22511                    <h4 class="sx-0ace2b19" >Line-length compliance column</h4>
22512                    <select name="style_col_threshold" id="style_col_threshold">
22513                      <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
22514                      <option value="100">100 columns (Uber Go, Google Java)</option>
22515                      <option value="120">120 columns (Uber Go max, Kotlin)</option>
22516                    </select>
22517                  </div>
22518                  <div class="explainer-card prominent sx-38965f9b" >
22519                    <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>
22520                    <div class="code-sample sx-161ac0cc" ># style_col_threshold = 80  (PEP 8, Google, gofmt)
22521# style_col_threshold = 100 (Uber Go, Google Java)
22522# style_col_threshold = 120 (Uber Go max, Kotlin)
22523# Files where &lt;= 5% of lines exceed the limit
22524# are counted as "N-col compliant" in the report.</div>
22525                  </div>
22526                </div>
22527                <div class="preset-inline-row">
22528                  <div class="toggle-card sx-38965f9b" >
22529                    <div class="field-help-title">Score alert threshold</div>
22530                    <h4 class="sx-0ace2b19" >Low-score file alert</h4>
22531                    <select name="style_score_threshold" id="style_score_threshold">
22532                      <option value="0" selected>Off — no threshold (default)</option>
22533                      <option value="40">40% — flag poorly styled files</option>
22534                      <option value="50">50% — flag below-average files</option>
22535                      <option value="60">60% — flag below-good files</option>
22536                      <option value="70">70% — flag below-strong files</option>
22537                    </select>
22538                  </div>
22539                  <div class="explainer-card prominent sx-38965f9b" >
22540                    <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>
22541                    <div class="code-sample sx-161ac0cc" ># style_score_threshold = 0   (off, default)
22542# style_score_threshold = 50  (flag files &lt; 50%)
22543# Low-scoring files get a red left-border in the
22544# per-file style breakdown table.</div>
22545                  </div>
22546                </div>
22547              </div>
22548
22549              <div class="always-tracked-tip">
22550                <div class="always-tracked-tip-icon">ℹ</div>
22551                <div class="always-tracked-tip-body">
22552                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
22553                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
22554                  <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 class="sx-c42f23b8" >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>
22555                </div>
22556              </div>
22557
22558              <div class="subsection-bar">Advanced Metrics</div>
22559              <div class="scan-rules-grid">
22560                <div class="preset-inline-row">
22561                  <div class="toggle-card sx-38965f9b" >
22562                    <div class="field-help-title">COCOMO mode</div>
22563                    <h4 class="sx-0ace2b19" >Cost estimation model</h4>
22564                    <select name="cocomo_mode" id="cocomo_mode">
22565                      <option value="organic" selected>Organic — small team, familiar domain (default)</option>
22566                      <option value="semi_detached">Semi-detached — mixed constraints</option>
22567                      <option value="embedded">Embedded — tight hardware/OS constraints</option>
22568                    </select>
22569                  </div>
22570                  <div class="explainer-card prominent sx-38965f9b" >
22571                    <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>
22572                    <div class="code-sample sx-161ac0cc" ># Organic:      Effort = 2.4 × KSLOC^1.05
22573# Semi-detached: Effort = 3.0 × KSLOC^1.12
22574# Embedded:     Effort = 3.6 × KSLOC^1.20
22575# All modes: Schedule = 2.5 × Effort^d</div>
22576                  </div>
22577                </div>
22578                <div class="preset-inline-row">
22579                  <div class="toggle-card sx-38965f9b" >
22580                    <div class="field-help-title">Complexity alert</div>
22581                    <h4 class="sx-0ace2b19" >Complexity score alert threshold</h4>
22582                    <input class="sx-31b6fe48" type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert"  />
22583                  </div>
22584                  <div class="explainer-card prominent sx-38965f9b" >
22585                    <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>
22586                    <div class="code-sample sx-161ac0cc" ># 0 or blank = no alert (default)
22587# 50  = flag any file with &gt; 50 branch points
22588# 100 = flag any file with &gt; 100 branch points
22589# Files above the threshold are highlighted
22590# in the result page metric strip.</div>
22591                  </div>
22592                </div>
22593                <div class="preset-inline-row">
22594                  <div class="toggle-card sx-38965f9b" >
22595                    <div class="field-help-title">Git hotspots</div>
22596                    <h4 class="sx-0ace2b19" >Activity window (days)</h4>
22597                    <input class="sx-31b6fe48" type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable"  />
22598                  </div>
22599                  <div class="explainer-card prominent sx-38965f9b" >
22600                    <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>
22601                    <div class="code-sample sx-161ac0cc" ># 90  = last quarter (default)
22602# 30  = last month of activity
22603# 365 = last year
22604# 0   = disable the hotspots table
22605# Adds Commits + Last-changed columns to CSV.</div>
22606                  </div>
22607                </div>
22608                <div class="preset-inline-row">
22609                  <div class="toggle-card sx-38965f9b" >
22610                    <div class="field-help-title">Code ownership</div>
22611                    <h4 class="sx-0ace2b19" >Per-author attribution (git blame)</h4>
22612                    <select name="attribution" id="attribution">
22613                      <option value="enabled" selected>On — attribute lines per author (default)</option>
22614                      <option value="disabled">Off — skip the blame pass</option>
22615                    </select>
22616                    <div class="attrib-estimate hidden" id="attrib-estimate" role="status"></div>
22617                  </div>
22618                  <div class="explainer-card prominent sx-38965f9b" >
22619                    <div class="advanced-rule-description"><strong>Purpose:</strong> When on, oxide-sloc runs <code>git blame</code> on every analyzed file and attributes each physical line to the author who last touched it, split into <strong>code / comment / blank</strong> per contributor. Results appear on the Code Ownership page and in the HTML/PDF/CSV reports.<br /><strong>Requires</strong> the scanned path to be a git repository. <strong>On by default.</strong><br /><strong>Speed:</strong> the blame pass runs after file counting and is the slowest stage on big trees — it does one <code>git blame</code> per file, so cost scales with file count (submodules included). It is parallelized across CPU cores and the scan screen shows a live "Blamed N / M" counter, but on a repo with tens of thousands of files it can still take a few minutes. Turn it off for the fastest possible scan when you don&#39;t need ownership data. Same-email identities are merged automatically and the repo <code>.mailmap</code> is honoured.</div>
22620                    <div class="code-sample sx-161ac0cc" ># On  = per-author code/comment/blank ownership (default)
22621# Off = skip blame, no ownership data
22622# CLI equivalent: analyze --no-attribution to disable
22623# View results at /code-ownership</div>
22624                  </div>
22625                </div>
22626                <div class="preset-inline-row">
22627                  <div class="toggle-card sx-38965f9b" >
22628                    <div class="field-help-title">Duplicate handling</div>
22629                    <h4 class="sx-0ace2b19" >Duplicate file detection</h4>
22630                    <select name="exclude_duplicates" id="exclude_duplicates">
22631                      <option value="disabled" selected>Detect and report only (default)</option>
22632                      <option value="enabled">Detect and exclude from SLOC totals</option>
22633                    </select>
22634                  </div>
22635                  <div class="explainer-card prominent sx-38965f9b" >
22636                    <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>
22637                    <div class="code-sample sx-161ac0cc" ># A repo with 3 identical config files:
22638# detect only   → all 3 counted in SLOC
22639# exclude dupes → 1 counted, 2 excluded
22640# Duplicate groups chip always shows the count.</div>
22641                  </div>
22642                </div>
22643                <div class="always-tracked-tip sx-a9d31d9e" >
22644                  <div class="always-tracked-tip-icon">ℹ</div>
22645                  <div class="always-tracked-tip-body">
22646                    <div class="field-help-title">Always computed &mdash; every scan produces these automatically</div>
22647                    <div class="always-tracked-metrics-row">
22648                      <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
22649                      <div><strong>Logical SLOC</strong>Executable statements &mdash; C-family, Python, Ruby, Shell &amp; more.</div>
22650                      <div><strong>ULOC &amp; DRYness</strong>De-duplicates lines project-wide; DRYness&nbsp;%&nbsp;=&nbsp;ULOC&nbsp;&divide;&nbsp;Code&nbsp;Lines.</div>
22651                      <div><strong>COCOMO&nbsp;I</strong>Converts total SLOC into effort, schedule &amp; team-size estimates.</div>
22652                    </div>
22653                    <div class="hint sx-a33b8fc2" >All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
22654                  </div>
22655                </div>
22656              </div>
22657
22658              <div class="wizard-actions">
22659                <div class="left">
22660                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
22661                </div>
22662                <div class="right">
22663                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
22664                </div>
22665              </div>
22666            </div>
22667
22668            <div class="wizard-step" data-step="3">
22669              <div class="section">
22670                <div class="section-kicker">Step 3</div>
22671                <h2>Output and report identity</h2>
22672                <p class="card-subtitle step3-subtitle sx-32fb29ef" >Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
22673                <div class="preset-kv-row">
22674                  <div class="toggle-card sx-38965f9b" >
22675                    <div class="field-help-title sx-fffdd52c" >Scan configuration</div>
22676                    <h4 class="sx-58629c9c" >Scan preset</h4>
22677                    <select id="scan_preset">
22678                      <option value="balanced">Balanced local scan</option>
22679                      <option value="code_focused">Code focused</option>
22680                      <option value="comment_audit">Comment audit</option>
22681                      <option value="deep_review">Deep review</option>
22682                    </select>
22683                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
22684                  </div>
22685                  <div class="explainer-card">
22686                    <div class="field-help-title">Selected scan preset</div>
22687                    <div class="explainer-body" id="scan-preset-description"></div>
22688                    <div class="preset-summary-row" id="scan-preset-summary"></div>
22689                    <div class="code-sample" id="scan-preset-example"></div>
22690                    <div class="preset-note" id="scan-preset-note"></div>
22691                  </div>
22692                </div>
22693                <hr class="step3-separator" />
22694                <div class="preset-kv-row">
22695                  <div class="toggle-card sx-38965f9b" >
22696                    <div class="field-help-title sx-fffdd52c" >Output configuration</div>
22697                    <h4 class="sx-58629c9c" >Artifact preset</h4>
22698                    <select id="artifact_preset">
22699                      <option value="review">Review bundle</option>
22700                      <option value="full">Full bundle</option>
22701                      <option value="html_only">HTML only</option>
22702                      <option value="machine">Machine bundle</option>
22703                    </select>
22704                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
22705                  </div>
22706                  <div class="explainer-card">
22707                    <div class="field-help-title">Selected artifact preset</div>
22708                    <div class="explainer-body" id="artifact-preset-description"></div>
22709                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
22710                    <div class="code-sample" id="artifact-preset-example"></div>
22711                  </div>
22712                </div>
22713              </div>
22714
22715              <div class="section section-spacer-top">
22716                <div class="output-field-row">
22717                  <div class="field">
22718                    <label for="output_dir">Output directory</label>
22719                    {% if server_mode %}
22720                    <div class="input-group compact">
22721                      <input class="sx-338e056b" id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly  />
22722                    </div>
22723                    <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
22724                    {% else %}
22725                    <div class="input-group compact">
22726                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
22727                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
22728                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
22729                    </div>
22730                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
22731                    {% endif %}
22732                  </div>
22733                  <div class="output-field-aside">
22734                    <strong>Where reports land</strong>
22735                    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.
22736                  </div>
22737                </div>
22738              </div>
22739
22740              <div class="section section-spacer-top">
22741                <div class="output-field-row">
22742                  <div class="field">
22743                    <label for="report_title">Report title</label>
22744                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
22745                    <div class="hint">Appears in HTML and PDF output headers.</div>
22746                  </div>
22747                  <div class="output-field-aside">
22748                    <strong>Shown in exported artifacts</strong>
22749                    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.
22750                  </div>
22751                </div>
22752              </div>
22753
22754              <div class="section section-spacer-top">
22755                <div class="output-field-row">
22756                  <div class="field">
22757                    <label for="report_header_footer">Report header / footer</label>
22758                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
22759                    <div class="hint sx-72d22148" >Printed on every HTML/PDF page — company name, project ID, or scanner tag.</div>
22760                  </div>
22761                  <div class="output-field-aside">
22762                    <strong>Page-level identification</strong>
22763                    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.
22764                  </div>
22765                </div>
22766              </div>
22767
22768              <div class="wizard-actions">
22769                <div class="left">
22770                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
22771                </div>
22772                <div class="right">
22773                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
22774                </div>
22775              </div>
22776            </div>
22777
22778            <div class="wizard-step" data-step="4">
22779              <div class="section">
22780                <div class="section-kicker">Step 4</div>
22781                <h2>Review selections and run</h2>
22782                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
22783                <div class="review-grid">
22784                  <div class="review-card highlight">
22785                    <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>
22786                    <ul id="review-scan-summary"></ul>
22787                  </div>
22788                  <div class="review-card highlight">
22789                    <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>
22790                    <ul id="review-count-summary"></ul>
22791                  </div>
22792                  <div class="review-card">
22793                    <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>
22794                    <ul id="review-artifact-summary"></ul>
22795                    <ul class="sx-44293b6e" id="review-output-summary" ></ul>
22796                  </div>
22797                  <div class="review-card">
22798                    <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>
22799                    <ul id="review-preview-summary"></ul>
22800                  </div>
22801                </div>
22802                <div class="review-attrib-warn hidden" id="review-attrib-warn" role="alert"></div>
22803              </div>
22804
22805              <div class="wizard-actions">
22806                <div class="left">
22807                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
22808                </div>
22809                <div class="right">
22810                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
22811                </div>
22812              </div>
22813            </div>
22814            {% if server_mode %}
22815            <input class="sx-6aa34d74" type="file" id="dir-upload-input" webkitdirectory multiple  aria-hidden="true">
22816            <input class="sx-6aa34d74" type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json"  aria-hidden="true">
22817            {% endif %}
22818          </form>
22819        </div>
22820      </section>
22821    </div>
22822  </div>
22823
22824  <script nonce="{{ csp_nonce }}">
22825    (function () {
22826      function startScanPhase() {
22827        var phaseEl = document.getElementById("scan-phase");
22828        if (!phaseEl) return;
22829        var phases = [
22830          "Discovering files...",
22831          "Decoding file encodings...",
22832          "Detecting languages...",
22833          "Analyzing source lines...",
22834          "Applying counting policies...",
22835          "Aggregating results...",
22836          "Rendering report..."
22837        ];
22838        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
22839        var i = 0;
22840        function next() {
22841          phaseEl.style.opacity = "0";
22842          setTimeout(function () {
22843            phaseEl.textContent = phases[i];
22844            phaseEl.style.opacity = "0.85";
22845            var delay = durations[i] || 1800;
22846            i++;
22847            if (i < phases.length) { setTimeout(next, delay); }
22848          }, 200);
22849        }
22850        next();
22851      }
22852
22853      var form = document.getElementById("analyze-form");
22854      var loading = document.getElementById("loading");
22855      var submitButton = document.getElementById("submit-button");
22856      var pathInput = document.getElementById("path");
22857      var GIT_MODE = !!(pathInput && pathInput.readOnly);
22858      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
22859      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
22860      var outputDirInput = document.getElementById("output_dir");
22861      var reportTitleInput = document.getElementById("report_title");
22862      var previewPanel = document.getElementById("preview-panel");
22863      var refreshButton = document.getElementById("refresh-preview");
22864      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
22865      var useSamplePath = document.getElementById("use-sample-path");
22866      var useDefaultOutput = document.getElementById("use-default-output");
22867      var browsePath = document.getElementById("browse-path");
22868      var browseOutputDir = document.getElementById("browse-output-dir");
22869      var browseCoverage = document.getElementById("browse-coverage");
22870      var coverageInput = document.getElementById("coverage_file");
22871      var covScanStatus = document.getElementById("cov-scan-status");
22872      var coverageSuggestTimer = null;
22873      var covAutoFilled = false;
22874      var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
22875
22876      // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
22877      (function() {
22878        var ids = ["path", "output_dir"];
22879        ids.forEach(function(id) {
22880          var el = document.getElementById(id);
22881          if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
22882        });
22883      }());
22884      function fmtBytes(b) {
22885        b = Number(b) || 0;
22886        if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
22887        if (b >= 1048576)    return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
22888        if (b >= 1024)       return Math.round(b / 1024) + ' KB';
22889        return b + ' B';
22890      }
22891      var themeToggle = document.getElementById("theme-toggle");
22892
22893      function showBannerToast(msg, isError, opts) {
22894        opts = opts || {};
22895        var t = document.createElement('div');
22896        t.className = isError ? 'toast-error' : 'toast-success';
22897        var topPos = opts.top ? '80px' : null;
22898        t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
22899          'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
22900          'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
22901          'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
22902        if (opts.icon) {
22903          var inner = document.createElement('span');
22904          inner.innerHTML = opts.icon + ' ';
22905          t.appendChild(inner);
22906        }
22907        t.appendChild(document.createTextNode(msg));
22908        document.body.appendChild(t);
22909        setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
22910      }
22911      var mixedLinePolicy = document.getElementById("mixed_line_policy");
22912      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
22913      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
22914      var scanPreset = document.getElementById("scan_preset");
22915      var artifactPreset = document.getElementById("artifact_preset");
22916      var includeGlobsInput = document.getElementById("include_globs");
22917      var excludeGlobsInput = document.getElementById("exclude_globs");
22918
22919      // Include globs scope badge — updates reactively as the user types.
22920      (function() {
22921        var badge = document.getElementById("include-scope-badge");
22922        if (!badge || !includeGlobsInput) return;
22923        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> ';
22924        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> ';
22925        function update() {
22926          var val = includeGlobsInput.value.trim();
22927          if (!val) {
22928            badge.className = "include-scope-badge scope-all";
22929            badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
22930          } else {
22931            var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
22932            badge.className = "include-scope-badge scope-narrow";
22933            badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
22934          }
22935        }
22936        includeGlobsInput.addEventListener("input", update);
22937        update();
22938      }());
22939
22940      // Quick-exclude chips — append pattern to exclude_globs textarea.
22941      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
22942        chip.addEventListener("click", function() {
22943          var pattern = chip.getAttribute("data-pattern") || "";
22944          if (!pattern || !excludeGlobsInput) return;
22945          var current = excludeGlobsInput.value.trim();
22946          // For the "skip all" chip, replace any existing dep patterns cleanly.
22947          var patterns = pattern.split("\n");
22948          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
22949          var added = false;
22950          patterns.forEach(function(p) {
22951            p = p.trim();
22952            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
22953          });
22954          if (added) {
22955            excludeGlobsInput.value = lines.join("\n");
22956            excludeGlobsInput.dispatchEvent(new Event("input"));
22957          }
22958          chip.classList.add("active");
22959        });
22960      });
22961
22962      var liveReportTitle = document.getElementById("live-report-title");
22963      var navProjectPill = document.getElementById("nav-project-pill");
22964      var navProjectTitle = document.getElementById("nav-project-title");
22965      var reportTitlePreview = null;
22966      var wizardProgressFill = document.getElementById("wizard-progress-fill");
22967      var wizardProgressValue = document.getElementById("wizard-progress-value");
22968      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
22969      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
22970      var reportTitleTouched = false;
22971      var currentStep = 1;
22972      var previewTimer = null;
22973      var _previewGen = 0;
22974      // True while the scope preview (local) / project upload (server mode) is in
22975      // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
22976      // user can't advance past a project whose scope/upload isn't ready yet.
22977      var previewLoading = false;
22978      // Set when the current preview reports multiple independent git repos under
22979      // the selected root. Advancing past step 1 is blocked until the user ticks
22980      // the acknowledgement checkbox (or re-selects a single repository).
22981      var multiRepoBlocked = false;
22982      function step1ForwardBlocked() {
22983        return previewLoading || multiRepoBlocked;
22984      }
22985      function refreshStep1Gate() {
22986        var nextBtn = document.getElementById("step1-next");
22987        if (nextBtn) {
22988          var blocked = step1ForwardBlocked();
22989          nextBtn.classList.toggle("is-blocked", blocked);
22990          nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
22991        }
22992      }
22993      function setPreviewLoading(loading) {
22994        previewLoading = !!loading;
22995        var gate = document.getElementById("preview-gate-status");
22996        refreshStep1Gate();
22997        if (gate) {
22998          var txt = gate.querySelector(".preview-gate-text");
22999          if (txt) txt.textContent = SERVER_MODE
23000            ? "Uploading & scanning project…"
23001            : "Scanning project scope…";
23002          gate.style.display = previewLoading ? "flex" : "none";
23003        }
23004      }
23005      // Info button on the gate: scroll up to the live scope preview so the user
23006      // can see exactly what is being scanned (elapsed time + rotating status).
23007      var previewGateInfo = document.getElementById("preview-gate-info");
23008      if (previewGateInfo) {
23009        previewGateInfo.addEventListener("click", function () {
23010          var target = document.getElementById("preview-panel");
23011          if (!target) return;
23012          target.scrollIntoView({ behavior: "smooth", block: "center" });
23013          target.classList.add("preview-panel-flash");
23014          setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
23015        });
23016      }
23017      var quickScanBtn = document.getElementById("quick-scan-btn");
23018
23019      function dismissAnalysisModal() {
23020        if (loading) loading.classList.remove("active");
23021        document.body.classList.remove("modal-open");
23022        ["lc-err","lc-warn","lc-attrib-note","lc-actions","lc-cancelled"].forEach(function(id) {
23023          var el = document.getElementById(id);
23024          if (el) el.classList.add("hidden");
23025        });
23026        var cancelBtn = document.getElementById("lc-cancel-btn");
23027        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
23028        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
23029        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
23030        var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
23031        for (var ri=1;ri<=5;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
23032        var rof=document.getElementById("lc-overall-fill");if(rof)rof.style.width="0%";
23033        var rop=document.getElementById("lc-overall-pct");if(rop)rop.textContent="0%";
23034        var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
23035        var rfc=document.getElementById("lc-files-card");if(rfc){rfc.classList.add("hidden");var rfl=rfc.querySelector(".lc-metric-label");if(rfl)rfl.textContent="Files";}
23036        var rslbl=rsc?rsc.querySelector(".lc-metric-label"):null;if(rslbl)rslbl.textContent="Files/sec";
23037        var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
23038        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
23039        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
23040        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
23041        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
23042      }
23043
23044      var lcDismissBtn = document.getElementById("lc-dismiss");
23045      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
23046
23047      // When the browser restores this page from bfcache (Back button after navigating to results),
23048      // the loading overlay would still be showing its active state. Dismiss it immediately.
23049      window.addEventListener("pageshow", function(e) {
23050        if (e.persisted) { dismissAnalysisModal(); }
23051      });
23052
23053      function startAsyncAnalysis(formData) {
23054        var gitRepo = (formData.get("git_repo") || "").toString();
23055        var gitRef  = (formData.get("git_ref")  || "").toString();
23056        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
23057        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
23058
23059        var pathEl = document.getElementById("lc-path-text");
23060        if (pathEl) pathEl.textContent = displayPath;
23061
23062        ["lc-err","lc-warn","lc-attrib-note","lc-actions","lc-cancelled"].forEach(function(id) {
23063          var el = document.getElementById(id);
23064          if (el) el.classList.add("hidden");
23065        });
23066        var cancelBtn = document.getElementById("lc-cancel-btn");
23067        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
23068        var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
23069        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
23070        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
23071        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
23072        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
23073        var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
23074        for (var si=1;si<=5;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
23075        var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
23076        var of0=document.getElementById("lc-overall-fill");if(of0)of0.style.width="0%";
23077        var op0=document.getElementById("lc-overall-pct");if(op0)op0.textContent="0%";
23078
23079        if (loading) loading.classList.add("active");
23080        document.body.classList.add("modal-open");
23081
23082        var startTime = Date.now();
23083        var elapsedTimer = setInterval(function() {
23084          var s = Math.floor((Date.now() - startTime) / 1000);
23085          var el = document.getElementById("lc-elapsed");
23086          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
23087        }, 1000);
23088
23089        var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now(), lastWasAttrib = false;
23090
23091        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();}
23092
23093        var PHASE_DESC = {
23094          'Starting': 'Initializing language analyzers and loading configuration\u2026',
23095          'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
23096          'Running': 'Running the lexical state machine across all discovered source files\u2026',
23097          'Attributing authorship': 'Running git blame on every source file (including submodules) to attribute each line to its author \u2014 the slowest stage on large repositories\u2026',
23098          'Summarizing submodules': 'Detecting git submodules and rolling up per-submodule line totals\u2026',
23099          'Computing metrics': 'Computing ULOC, duplicate groups, complexity and COCOMO estimates\u2026',
23100          'Reading git history': 'Reading recent git history to rank change hotspots\u2026',
23101          'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
23102          'Done': 'Analysis complete \u2014 loading your results\u2026',
23103          'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
23104        };
23105        // 5 stages: 1 Discover (walk+count), 2 Analyze (metrics/submodules/git history),
23106        // 3 Attribute (git blame), 4 Report (write artifacts), 5 Done.
23107        var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':1,'Summarizing submodules':2,'Computing metrics':2,'Reading git history':2,'Attributing authorship':3,'Writing reports':4,'Done':5};
23108        // Overall-progress bands [start,end] per phase. The counter-driven phases (Discover via
23109        // files, Attribute via blame) scale within their band; brief phases jump to their band end.
23110        // Bands are weighted by real time cost: on a large repo attribution is ~99% of the wall time,
23111        // so it owns the widest band and the bar spends most of its life there instead of at "done".
23112        var PHASE_BAND = {
23113          'Starting':[0,2],'Scanning files':[2,15],'Running':[2,15],
23114          'Summarizing submodules':[15,18],'Computing metrics':[18,20],'Reading git history':[20,22],
23115          'Attributing authorship':[22,97],'Writing reports':[97,99],'Done':[100,100],'Failed':[100,100]
23116        };
23117        var lastPct = 0;
23118        function overallPct(data) {
23119          var phase = (data && data.phase) || 'Starting';
23120          var band = PHASE_BAND[phase] || [lastPct, lastPct];
23121          var frac = 1;
23122          if (phase === 'Scanning files' || phase === 'Running') {
23123            frac = (data.files_total > 0) ? (data.files_done / data.files_total) : 0;
23124          } else if (phase === 'Attributing authorship') {
23125            frac = (data.attrib_total > 0) ? (data.attrib_done / data.attrib_total) : 0;
23126          }
23127          var pct = band[0] + (band[1] - band[0]) * Math.max(0, Math.min(1, frac));
23128          lastPct = Math.max(lastPct, pct); // never let the bar go backwards
23129          return lastPct;
23130        }
23131        function setOverall(pct) {
23132          var fill = document.getElementById("lc-overall-fill");
23133          var lbl = document.getElementById("lc-overall-pct");
23134          var p = Math.max(0, Math.min(100, Math.round(pct)));
23135          if (fill) fill.style.width = p + "%";
23136          if (lbl) lbl.textContent = p + "%";
23137        }
23138        function lcSetPhase(txt) {
23139          var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
23140          var desc = document.getElementById("lc-stage-desc");
23141          if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
23142          var step = PHASE_STEP[txt] || 1;
23143          for (var i=1;i<=5;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");}
23144        }
23145
23146        function lcShowCancelled() {
23147          clearInterval(elapsedTimer);
23148          var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
23149          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
23150          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
23151          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
23152          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
23153          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
23154          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
23155          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
23156          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
23157          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
23158        }
23159
23160        var lcCancelBtn = document.getElementById("lc-cancel-btn");
23161        if (lcCancelBtn) {
23162          lcCancelBtn.onclick = function() {
23163            if (!activeWaitId) { dismissAnalysisModal(); return; }
23164            lcCancelBtn.disabled = true;
23165            lcCancelBtn.textContent = "Cancelling\u2026";
23166            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
23167              .then(function() { lcShowCancelled(); })
23168              .catch(function() { lcShowCancelled(); });
23169          };
23170        }
23171
23172        function lcShowError(msg) {
23173          clearInterval(elapsedTimer);
23174          var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
23175          lcSetPhase("Failed");
23176          var msgEl = document.getElementById("lc-err-msg");
23177          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
23178          var errEl = document.getElementById("lc-err");
23179          var actEl = document.getElementById("lc-actions");
23180          if (errEl) errEl.classList.remove("hidden");
23181          if (actEl) actEl.classList.remove("hidden");
23182          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
23183          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
23184        }
23185
23186        function lcPoll(waitId) {
23187          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
23188            .then(function(r) {
23189              if (!r.ok) throw new Error("HTTP " + r.status);
23190              return r.json();
23191            })
23192            .then(function(data) {
23193              pollRetries = 0;
23194              if (data.state === "complete") {
23195                clearInterval(elapsedTimer);
23196                lcSetPhase("Done");
23197                setOverall(100);
23198                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
23199              } else if (data.state === "failed") {
23200                lcShowError(data.message);
23201              } else if (data.state === "cancelled") {
23202                lcShowCancelled();
23203              } else {
23204                var s = Math.floor((Date.now() - startTime) / 1000);
23205                if (s > 90 && !warnShown) {
23206                  warnShown = true;
23207                  var w = document.getElementById("lc-warn");
23208                  if (w) w.classList.remove("hidden");
23209                }
23210                lcSetPhase(data.phase || "Running");
23211                // During the git-blame attribution pass the file counter is already maxed out, so
23212                // switch the live metric over to blame progress — otherwise the modal looks frozen
23213                // at "files done / files total" for the slowest stage of the whole scan.
23214                var attribActive = (data.attrib_total || 0) > 0;
23215                var curDone = attribActive ? (data.attrib_done || 0) : (data.files_done || 0);
23216                var curTotal = attribActive ? (data.attrib_total || 0) : (data.files_total || 0);
23217                var attribNote = document.getElementById("lc-attrib-note");
23218                if (attribNote) attribNote.classList.toggle("hidden", !attribActive);
23219                if (curTotal > 0) {
23220                  var card = document.getElementById("lc-files-card");
23221                  if (card) card.classList.remove("hidden");
23222                  var fLabel = card ? card.querySelector(".lc-metric-label") : null;
23223                  if (fLabel) fLabel.textContent = attribActive ? "Blamed" : "Files";
23224                  var el = document.getElementById("lc-files");
23225                  if (el) el.textContent = fmt(curDone) + " / " + fmt(curTotal);
23226                  var now = Date.now();
23227                  // Reset the rate baseline when the counter source flips (files -> blamed) so the
23228                  // speed reading doesn't show a bogus negative spike on the transition.
23229                  if (attribActive !== lastWasAttrib) { lastFd = attribActive ? 0 : curDone; }
23230                  var fdelta = curDone - lastFd, tdelta = (now - lastFdTime) / 1000;
23231                  if (fdelta > 0 && tdelta > 0.4) {
23232                    var fps = Math.round(fdelta / tdelta);
23233                    var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
23234                    var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
23235                    var spLabel = spCard ? spCard.querySelector(".lc-metric-label") : null;
23236                    if (spLabel) spLabel.textContent = attribActive ? "Blamed/sec" : "Files/sec";
23237                  }
23238                  lastFd = curDone; lastFdTime = now; lastWasAttrib = attribActive;
23239                }
23240                setOverall(overallPct(data));
23241                setTimeout(function() { lcPoll(waitId); }, 1500);
23242              }
23243            })
23244            .catch(function() {
23245              pollRetries++;
23246              if (pollRetries >= 5) {
23247                lcShowError("Lost connection to server. Reload to check status.");
23248              } else {
23249                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
23250              }
23251            });
23252        }
23253
23254        var params = new URLSearchParams(formData);
23255        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
23256          .then(function(r) {
23257            var waitId = r.headers.get("x-wait-id");
23258            if (!waitId) { window.location.href = "/scan"; return; }
23259            activeWaitId = waitId;
23260            setTimeout(function() { lcPoll(waitId); }, 1500);
23261          })
23262          .catch(function(err) {
23263            lcShowError("Could not reach server: " + (err.message || err));
23264          });
23265      }
23266
23267      if (quickScanBtn) {
23268        quickScanBtn.addEventListener("click", function () {
23269          var pathVal = pathInput ? pathInput.value.trim() : "";
23270          if (!pathVal) {
23271            alert("Please enter or browse to a project path first.");
23272            return;
23273          }
23274          quickScanBtn.disabled = true;
23275          quickScanBtn.textContent = "Scanning...";
23276          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
23277          startAsyncAnalysis(new FormData(form));
23278        });
23279      }
23280
23281      var mixedPolicyInfo = {
23282        code_only: {
23283          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.",
23284          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'
23285        },
23286        code_and_comment: {
23287          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.",
23288          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'
23289        },
23290        comment_only: {
23291          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.",
23292          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'
23293        },
23294        separate_mixed_category: {
23295          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.",
23296          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'
23297        }
23298      };
23299
23300      var scanPresetInfo = {
23301        balanced: {
23302          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.",
23303          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
23304          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
23305          note: "Best when you want a stable local overview before making deeper adjustments.",
23306          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
23307        },
23308        code_focused: {
23309          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
23310          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
23311          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
23312          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
23313          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
23314        },
23315        comment_audit: {
23316          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
23317          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
23318          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
23319          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
23320          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
23321        },
23322        deep_review: {
23323          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
23324          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
23325          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
23326          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
23327          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
23328        }
23329      };
23330
23331      var artifactPresetInfo = {
23332        review: {
23333          description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
23334          chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
23335          example: "Ideal for a quick local review before sharing results."
23336        },
23337        full: {
23338          description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
23339          chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
23340          example: "Use when producing a deliverable or storing a snapshot for future comparison."
23341        },
23342        html_only: {
23343          description: "Standalone HTML report only. No PDF generation, no data files.",
23344          chips: ["HTML only"],
23345          example: "Fastest option when you only need to open the report in a browser."
23346        },
23347        machine: {
23348          description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
23349          chips: ["JSON", "CSV", "no HTML", "no PDF"],
23350          example: "Use in CI to capture metrics without generating visual reports."
23351        }
23352      };
23353
23354      function applyArtifactPreset() {
23355        var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
23356        if (!info) return;
23357        var descEl = document.getElementById("artifact-preset-description");
23358        var exampleEl = document.getElementById("artifact-preset-example");
23359        if (descEl) descEl.textContent = info.description;
23360        if (exampleEl) exampleEl.textContent = info.example;
23361        renderPresetChips("artifact-preset-summary", info.chips);
23362      }
23363
23364      function applyTheme(theme) {
23365        if (theme === "dark") document.body.classList.add("dark-theme");
23366        else document.body.classList.remove("dark-theme");
23367      }
23368
23369      function loadSavedTheme() {
23370        var saved = null;
23371        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
23372        applyTheme(saved === "dark" ? "dark" : "light");
23373      }
23374
23375      function updateScrollProgress() {
23376        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
23377        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
23378        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
23379        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
23380        var step = Math.min(Math.max(currentStep, 1), 4);
23381        var base = stepBase[step];
23382        var end  = stepEnd[step];
23383
23384        var scrollFrac = 0;
23385        var activePanel = document.querySelector(".wizard-step.active");
23386        if (activePanel) {
23387          var scrollTop = window.scrollY || window.pageYOffset || 0;
23388          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
23389          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
23390          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
23391          var scrolled = scrollTop + viewH - panelTop;
23392          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
23393        }
23394
23395        var percent = Math.round(base + (end - base) * scrollFrac);
23396        percent = Math.min(end, Math.max(base, percent));
23397        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
23398        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
23399      }
23400
23401      function updateWizardProgress() {
23402        updateScrollProgress();
23403      }
23404
23405      var stepDescriptions = [
23406        "Choose a project folder, apply scope filters, and preview which files will be counted.",
23407        "Configure how mixed code-plus-comment lines and docstrings are classified.",
23408        "Pick your output formats, scan preset, and where reports are saved.",
23409        "Review all settings and launch the analysis."
23410      ];
23411
23412      function updateStepNav(step) {
23413        var infoLabel = document.getElementById("step-nav-info-label");
23414        var infoDesc  = document.getElementById("step-nav-info-desc");
23415        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
23416        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
23417      }
23418
23419      function updateSidebarSummary() {
23420        var sumPath    = document.getElementById("sum-path");
23421        var sumPreset  = document.getElementById("sum-preset");
23422        var sumOutput  = document.getElementById("sum-output");
23423        var sidebarSummary = document.getElementById("sidebar-summary");
23424        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
23425        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
23426        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
23427        if (sumPath)   sumPath.textContent   = pathVal   || "\u2014";
23428        if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
23429        if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
23430        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
23431      }
23432
23433      function setStep(step, pushHistory) {
23434        currentStep = step;
23435        stepPanels.forEach(function (panel) {
23436          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
23437        });
23438        stepButtons.forEach(function (button) {
23439          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
23440        });
23441        var layoutEl = document.querySelector(".layout");
23442        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
23443        updateWizardProgress();
23444        updateStepNav(step);
23445        stepButtons.forEach(function(btn) {
23446          var t = Number(btn.getAttribute("data-step-target"));
23447          btn.classList.toggle("done", t < step);
23448        });
23449        updateSidebarSummary();
23450
23451        if (pushHistory !== false) {
23452          try {
23453            history.pushState({ wizardStep: step }, "", "#step" + step);
23454          } catch (e) {}
23455        }
23456
23457        window.scrollTo({ top: 0, behavior: "instant" });
23458      }
23459
23460      window.addEventListener("popstate", function (e) {
23461        if (e.state && e.state.wizardStep) {
23462          setStep(e.state.wizardStep, false);
23463        } else {
23464          var hashMatch = location.hash.match(/^#step([1-4])$/);
23465          if (hashMatch) setStep(Number(hashMatch[1]), false);
23466        }
23467      });
23468
23469      function inferTitleFromPath(value) {
23470        if (!value) return "project";
23471        var cleaned = value.replace(/[\/\\]+$/, "");
23472        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
23473        return parts.length ? parts[parts.length - 1] : value;
23474      }
23475
23476      function updateReportTitleFromPath() {
23477        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
23478        if (!reportTitleTouched) {
23479          reportTitleInput.value = inferred;
23480        }
23481        var title = reportTitleInput.value || inferred;
23482        if (liveReportTitle) liveReportTitle.textContent = title;
23483        if (reportTitlePreview) reportTitlePreview.textContent = title;
23484        document.title = "OxideSLOC | " + title;
23485
23486        var projectPath = (pathInput.value || "").trim();
23487        if (navProjectPill && navProjectTitle) {
23488          if (projectPath.length > 0) {
23489            navProjectTitle.textContent = inferred;
23490            navProjectPill.classList.add("visible");
23491          } else {
23492            navProjectTitle.textContent = "";
23493            navProjectPill.classList.remove("visible");
23494          }
23495        }
23496      }
23497
23498      function updateMixedPolicyUI() {
23499        var key = mixedLinePolicy.value || "code_only";
23500        var info = mixedPolicyInfo[key];
23501        document.getElementById("mixed-policy-description").textContent = info.description;
23502        document.getElementById("mixed-policy-example").textContent = info.example;
23503      }
23504
23505      function updatePythonDocstringUI() {
23506        var checked = !!pythonDocstrings.checked;
23507        document.getElementById("python-docstring-example").textContent = checked
23508          ? 'def greet():\n    """Greet the user."""  \u2190 comment\n    print("hi")'
23509          : 'def greet():\n    """Greet the user."""  \u2190 not counted\n    print("hi")';
23510        document.getElementById("python-docstring-live-help").textContent = checked
23511          ? "Enabled: docstrings contribute to comment-style totals."
23512          : "Disabled: docstrings are not counted as comment content.";
23513      }
23514
23515      function renderPresetChips(targetId, chips) {
23516        var target = document.getElementById(targetId);
23517        if (!target) return;
23518        target.innerHTML = (chips || []).map(function (chip) {
23519          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
23520        }).join('');
23521      }
23522
23523      function updatePresetDescriptions() {
23524        var scanInfo = scanPresetInfo[scanPreset.value];
23525        if (!scanInfo) return;
23526        document.getElementById("scan-preset-description").textContent = scanInfo.description;
23527        document.getElementById("scan-preset-example").textContent = scanInfo.example;
23528        document.getElementById("scan-preset-note").textContent = scanInfo.note;
23529        renderPresetChips("scan-preset-summary", scanInfo.chips);
23530      }
23531
23532      function applyScanPreset() {
23533        var info = scanPresetInfo[scanPreset.value];
23534        if (!info || !info.apply) return;
23535        mixedLinePolicy.value = info.apply.mixed;
23536        pythonDocstrings.checked = !!info.apply.docstrings;
23537        document.getElementById("generated_file_detection").value = info.apply.generated;
23538        document.getElementById("minified_file_detection").value = info.apply.minified;
23539        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
23540        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
23541        document.getElementById("binary_file_behavior").value = info.apply.binary;
23542        updateMixedPolicyUI();
23543        updatePythonDocstringUI();
23544      }
23545
23546      function updateReview() {
23547        var scanSummary = document.getElementById("review-scan-summary");
23548        var countSummary = document.getElementById("review-count-summary");
23549        var artifactSummary = document.getElementById("review-artifact-summary");
23550        var outputSummary = document.getElementById("review-output-summary");
23551        var previewSummary = document.getElementById("review-preview-summary");
23552        var readinessSummary = document.getElementById("review-readiness-summary");
23553        var includeText = document.getElementById("include_globs").value.trim();
23554        var excludeText = document.getElementById("exclude_globs").value.trim();
23555        var sidePathPreview = document.getElementById("side-path-preview");
23556        var sideOutputPreview = document.getElementById("side-output-preview");
23557        var sideTitlePreview = document.getElementById("side-title-preview");
23558
23559        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
23560        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
23561        if (sideTitlePreview) {
23562          var rt = document.getElementById("report_title");
23563          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
23564        }
23565
23566        scanSummary.innerHTML = ""
23567          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
23568          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
23569          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
23570
23571        countSummary.innerHTML = ""
23572          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
23573          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
23574          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
23575          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
23576          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
23577          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
23578          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
23579          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>"
23580          + "<li>" + attribReviewLine() + "</li>";
23581
23582        artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
23583
23584        outputSummary.innerHTML = ""
23585          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
23586          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
23587
23588        if (previewSummary) {
23589          if (GIT_MODE) {
23590            previewSummary.innerHTML = '<li class="sx-7f8edb5d" >Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
23591          } else {
23592          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
23593          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
23594          var statMap = {};
23595          statButtons.forEach(function (button) {
23596            var valueNode = button.querySelector('.scope-stat-value');
23597            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
23598          });
23599          previewSummary.innerHTML = ''
23600            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
23601            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
23602            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
23603            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
23604            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
23605            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
23606
23607          if (readinessSummary) {
23608            readinessSummary.innerHTML = ''
23609              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
23610              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
23611              + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
23612          }
23613          } // end else (non-GIT_MODE)
23614        }
23615        updateReviewAttribWarn();
23616      }
23617
23618      function escapeHtml(value) {
23619        return String(value)
23620          .replace(/&/g, "&amp;")
23621          .replace(/</g, "&lt;")
23622          .replace(/>/g, "&gt;")
23623          .replace(/"/g, "&quot;")
23624          .replace(/'/g, "&#39;");
23625      }
23626
23627      function isPythonVisible() {
23628        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
23629      }
23630
23631      function syncPythonVisibility() {
23632        var html = previewPanel.textContent || "";
23633        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
23634        pythonWraps.forEach(function (node) {
23635          node.classList.toggle("hidden", !hasPython);
23636        });
23637      }
23638
23639      function attachPreviewInteractions() {
23640        // Multiple-repository caution banner: gate step 1 until acknowledged, and
23641        // let each listed repo be picked as the scan root with one click.
23642        var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
23643        if (multiRepoBanner) {
23644          multiRepoBlocked = true;
23645          refreshStep1Gate();
23646          var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
23647          if (ackBox) {
23648            ackBox.addEventListener("change", function () {
23649              multiRepoBlocked = !ackBox.checked;
23650              refreshStep1Gate();
23651            });
23652          }
23653          var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
23654          repoButtons.forEach(function (btn) {
23655            btn.addEventListener("click", function () {
23656              var repoPath = btn.getAttribute("data-repo-path") || "";
23657              if (!repoPath || !pathInput) return;
23658              pathInput.value = repoPath;
23659              scrollInputToEnd(pathInput);
23660              updateReportTitleFromPath();
23661              autoSetOutputDir(repoPath);
23662              fetchProjectHistory(repoPath);
23663              loadPreview();
23664              updateReview();
23665            });
23666          });
23667        }
23668        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
23669        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
23670        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
23671        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
23672        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
23673        var searchInput = previewPanel.querySelector("#explorer-search");
23674        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
23675        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
23676        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
23677        var activeFilter = "all";
23678        var activeLanguage = "";
23679        var searchTerm = "";
23680        var currentSortKey = null;
23681        var currentSortOrder = "asc";
23682        var childRows = {};
23683
23684        rows.forEach(function (row) {
23685          var parentId = row.getAttribute("data-parent-id") || "";
23686          var rowId = row.getAttribute("data-row-id") || "";
23687          if (!childRows[parentId]) childRows[parentId] = [];
23688          childRows[parentId].push(rowId);
23689        });
23690
23691        function rowById(id) {
23692          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
23693        }
23694
23695        function hasCollapsedAncestor(row) {
23696          var parentId = row.getAttribute("data-parent-id");
23697          while (parentId) {
23698            var parent = rowById(parentId);
23699            if (!parent) break;
23700            if (parent.getAttribute("data-expanded") === "false") return true;
23701            parentId = parent.getAttribute("data-parent-id");
23702          }
23703          return false;
23704        }
23705
23706        function updateToggleGlyph(row) {
23707          var toggle = row.querySelector(".tree-toggle");
23708          if (!toggle) return;
23709          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
23710        }
23711
23712        function rowSortValue(row, key) {
23713          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
23714        }
23715
23716        function updateSortButtons() {
23717          sortButtons.forEach(function (button) {
23718            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
23719            var indicator = button.querySelector(".tree-sort-indicator");
23720            button.classList.toggle("active", isActive);
23721            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
23722            if (indicator) {
23723              indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
23724            }
23725          });
23726        }
23727
23728        function sortSiblingRows() {
23729          if (!treeContainer) {
23730            updateSortButtons();
23731            return;
23732          }
23733
23734          var rowMap = {};
23735          var childrenMap = {};
23736          rows.forEach(function (row) {
23737            var rowId = row.getAttribute("data-row-id");
23738            var parentId = row.getAttribute("data-parent-id") || "";
23739            rowMap[rowId] = row;
23740            if (!childrenMap[parentId]) childrenMap[parentId] = [];
23741            childrenMap[parentId].push(rowId);
23742          });
23743
23744          Object.keys(childrenMap).forEach(function (parentId) {
23745            if (!parentId) return;
23746            childrenMap[parentId].sort(function (a, b) {
23747              var rowA = rowMap[a];
23748              var rowB = rowMap[b];
23749              if (!currentSortKey) {
23750                return Number(a) - Number(b);
23751              }
23752              var valueA = rowSortValue(rowA, currentSortKey);
23753              var valueB = rowSortValue(rowB, currentSortKey);
23754              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
23755              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
23756              var fallbackA = rowSortValue(rowA, "name");
23757              var fallbackB = rowSortValue(rowB, "name");
23758              if (fallbackA < fallbackB) return -1;
23759              if (fallbackA > fallbackB) return 1;
23760              return Number(a) - Number(b);
23761            });
23762          });
23763
23764          var orderedIds = [];
23765          function pushChildren(parentId) {
23766            (childrenMap[parentId] || []).forEach(function (childId) {
23767              orderedIds.push(childId);
23768              pushChildren(childId);
23769            });
23770          }
23771
23772          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
23773            orderedIds.push(topId);
23774            pushChildren(topId);
23775          });
23776
23777          orderedIds.forEach(function (id) {
23778            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
23779          });
23780          updateSortButtons();
23781        }
23782
23783        function updateLanguageButtons() {
23784          languageButtons.forEach(function (button) {
23785            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
23786            var isActive = languageValue === activeLanguage;
23787            button.classList.toggle("active", isActive);
23788          });
23789        }
23790
23791        function rowSelfMatches(row) {
23792          var kind = row.getAttribute("data-kind");
23793          var status = row.getAttribute("data-status");
23794          var language = (row.getAttribute("data-language") || "").toLowerCase();
23795          var name = row.getAttribute("data-name-lower") || "";
23796          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
23797          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
23798          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
23799          var passesLanguage = !activeLanguage || language === activeLanguage;
23800          return passesFilter && passesSearch && passesLanguage;
23801        }
23802
23803        function hasMatchingDescendant(rowId) {
23804          return (childRows[rowId] || []).some(function (childId) {
23805            var childRow = rowById(childId);
23806            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
23807          });
23808        }
23809
23810        function rowMatches(row) {
23811          if (rowSelfMatches(row)) return true;
23812          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
23813        }
23814
23815        function resetViewState() {
23816          activeFilter = "all";
23817          activeLanguage = "";
23818          searchTerm = "";
23819          currentSortKey = null;
23820          currentSortOrder = "asc";
23821          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
23822          if (searchInput) searchInput.value = "";
23823          if (filterSelect) filterSelect.value = "all";
23824          updateLanguageButtons();
23825        }
23826
23827        function applyVisibility() {
23828          rows.forEach(function (row) {
23829            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
23830            row.classList.toggle("hidden-by-filter", !visible);
23831            row.style.display = visible ? "grid" : "none";
23832          });
23833          buttons.forEach(function (button) {
23834            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
23835          });
23836          if (filterSelect) filterSelect.value = activeFilter;
23837        }
23838
23839        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
23840        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
23841        var originalStats = {};
23842        buttons.forEach(function (btn) {
23843          var f = btn.getAttribute('data-filter');
23844          var v = btn.querySelector('.scope-stat-value');
23845          if (f && v) originalStats[f] = v.textContent;
23846        });
23847
23848        function applySubmoduleStats(statsJson) {
23849          try {
23850            var s = JSON.parse(statsJson);
23851            buttons.forEach(function (btn) {
23852              var f = btn.getAttribute('data-filter');
23853              var v = btn.querySelector('.scope-stat-value');
23854              if (!v) return;
23855              if (f === 'dir') v.textContent = s.dirs;
23856              else if (f === 'file') v.textContent = s.files;
23857              else if (f === 'supported') v.textContent = s.supported;
23858              else if (f === 'skipped') v.textContent = s.skipped;
23859              else if (f === 'unsupported') v.textContent = s.unsupported;
23860            });
23861          } catch (e) {}
23862        }
23863
23864        function restoreBaseRepoStats() {
23865          buttons.forEach(function (btn) {
23866            var f = btn.getAttribute('data-filter');
23867            var v = btn.querySelector('.scope-stat-value');
23868            if (v && originalStats[f]) v.textContent = originalStats[f];
23869          });
23870          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
23871          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
23872        }
23873
23874        submoduleChips.forEach(function (chip) {
23875          chip.addEventListener('click', function () {
23876            var statsJson = chip.getAttribute('data-sub-stats');
23877            if (!statsJson) return;
23878            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
23879            chip.classList.add('active');
23880            applySubmoduleStats(statsJson);
23881            if (baseRepoBtn) baseRepoBtn.style.display = '';
23882          });
23883        });
23884
23885        if (baseRepoBtn) {
23886          baseRepoBtn.addEventListener('click', function () {
23887            restoreBaseRepoStats();
23888            resetViewState();
23889            sortSiblingRows();
23890            applyVisibility();
23891          });
23892        }
23893
23894        buttons.forEach(function (button) {
23895          button.addEventListener("click", function () {
23896            var filterValue = button.getAttribute("data-filter") || "all";
23897            if (filterValue === "reset-view") {
23898              restoreBaseRepoStats();
23899              resetViewState();
23900              sortSiblingRows();
23901              applyVisibility();
23902              return;
23903            }
23904            activeFilter = filterValue;
23905            applyVisibility();
23906          });
23907        });
23908
23909        rows.forEach(function (row) {
23910          updateToggleGlyph(row);
23911          var toggle = row.querySelector(".tree-toggle");
23912          if (toggle) {
23913            toggle.addEventListener("click", function () {
23914              var expanded = row.getAttribute("data-expanded") !== "false";
23915              row.setAttribute("data-expanded", expanded ? "false" : "true");
23916              updateToggleGlyph(row);
23917              applyVisibility();
23918            });
23919          }
23920        });
23921
23922        actionButtons.forEach(function (button) {
23923          button.addEventListener("click", function () {
23924            var action = button.getAttribute("data-explorer-action");
23925            if (action === "expand-all") {
23926              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
23927            } else if (action === "collapse-all") {
23928              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
23929            } else if (action === "clear-filters") {
23930              resetViewState();
23931            }
23932            sortSiblingRows();
23933            applyVisibility();
23934          });
23935        });
23936
23937        if (filterSelect) {
23938          filterSelect.addEventListener("change", function () {
23939            activeFilter = filterSelect.value || "all";
23940            applyVisibility();
23941          });
23942        }
23943
23944        languageButtons.forEach(function (button) {
23945          button.addEventListener("click", function () {
23946            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
23947            updateLanguageButtons();
23948            applyVisibility();
23949          });
23950        });
23951
23952        sortButtons.forEach(function (button) {
23953          button.addEventListener("click", function () {
23954            var sortKey = button.getAttribute("data-sort-key");
23955            if (currentSortKey === sortKey) {
23956              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
23957            } else {
23958              currentSortKey = sortKey;
23959              currentSortOrder = "asc";
23960            }
23961            sortSiblingRows();
23962            applyVisibility();
23963          });
23964        });
23965
23966        if (searchInput) {
23967          searchInput.addEventListener("input", function () {
23968            searchTerm = searchInput.value.trim().toLowerCase();
23969            applyVisibility();
23970          });
23971        }
23972
23973        updateLanguageButtons();
23974        sortSiblingRows();
23975        applyVisibility();
23976      }
23977
23978      // ── Attribution (git blame) cost estimate ────────────────────────────────
23979      // Once the user manually changes the Code-ownership select we stop auto-defaulting it, so an
23980      // explicit choice is never overridden by a later estimate.
23981      var attributionTouched = false;
23982      var _attribEstGen = 0;
23983      var lastAttribEstimate = null; // most recent /api/attribution-estimate result for this path
23984      (function() {
23985        var sel = document.getElementById('attribution');
23986        if (sel) sel.addEventListener('change', function() {
23987          attributionTouched = true;
23988          // Re-render both the step-2 note and the step-4 review warning to reflect the new choice.
23989          renderAttribBanner();
23990          updateReviewAttribWarn();
23991        });
23992      }());
23993      function estFmt(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();}
23994      // Render the step-2 attribution note from the last estimate + the current toggle state.
23995      function renderAttribBanner() {
23996        var box = document.getElementById('attrib-estimate');
23997        var sel = document.getElementById('attribution');
23998        if (!box || !sel) return;
23999        var d = lastAttribEstimate;
24000        if (!d || !d.is_git || !d.blameable_files) { box.className = 'attrib-estimate hidden'; box.textContent = ''; return; }
24001        var files = estFmt(d.blameable_files), commits = estFmt(d.commit_count), dur = d.estimated_label || '';
24002        var sev = d.severity || 'light';
24003        var on = sel.value !== 'disabled';
24004        box.className = 'attrib-estimate est-' + sev;
24005        if (sev === 'heavy') {
24006          box.innerHTML = on
24007            ? '<b>Heads up — attribution is ON for a very large repo.</b> Blaming ' + files +
24008              ' files across ~' + commits + ' commits will make this scan take roughly <b>' + dur +
24009              '</b> longer. Turn it off here for a fast scan.'
24010            : '<b>Large history detected.</b> Attributing ' + files + ' files (~' + commits +
24011              ' commits) would take about <b>' + dur + '</b>, so per-author attribution has been turned <b>off</b>. Switch it on above to run it anyway.';
24012        } else if (sev === 'moderate') {
24013          box.innerHTML = (on ? 'With attribution on, this scan blames ' : 'Attribution (currently off) would blame ') +
24014            '<b>' + files + '</b> files (~' + commits + ' commits) and add about <b>' + dur + '</b>.';
24015        } else {
24016          box.innerHTML = 'Per-author attribution is quick here — about <b>' + (dur || '~1s') +
24017            '</b> for <b>' + files + '</b> files.';
24018        }
24019      }
24020      // Prominent step-4 (review) warning: only shown when attribution is ON and non-trivial.
24021      function updateReviewAttribWarn() {
24022        var warn = document.getElementById('review-attrib-warn');
24023        var sel = document.getElementById('attribution');
24024        if (!warn || !sel) return;
24025        var d = lastAttribEstimate;
24026        var on = sel.value !== 'disabled';
24027        if (!d || !d.is_git || !d.blameable_files || !on || (d.severity || 'light') === 'light') {
24028          warn.className = 'review-attrib-warn hidden'; warn.textContent = ''; return;
24029        }
24030        var files = estFmt(d.blameable_files), commits = estFmt(d.commit_count), dur = d.estimated_label || '';
24031        warn.className = 'review-attrib-warn ' + (d.severity === 'heavy' ? 'raw-heavy' : 'raw-moderate');
24032        warn.innerHTML = '<span><b>Per-author attribution is ON.</b> This scan will run <code>git blame</code> on <b>' +
24033          files + '</b> files across ~' + commits + ' commits, making it take roughly <b>' + dur +
24034          '</b> longer than a scan without it. To skip it, go back to step 2 (How it will be counted) and set Code ownership to Off.</span>';
24035      }
24036      // One-line attribution summary for the step-4 "How it will be counted" card.
24037      function attribReviewLine() {
24038        var sel = document.getElementById('attribution');
24039        var on = sel && sel.value !== 'disabled';
24040        var d = lastAttribEstimate;
24041        var suffix = '';
24042        if (d && d.is_git && d.blameable_files) {
24043          suffix = ' (' + estFmt(d.blameable_files) + ' files'
24044            + (on && d.estimated_label ? ', ~' + d.estimated_label : '') + ')';
24045        }
24046        return 'Per-author attribution (git blame): ' + (on ? 'on' : 'off') + suffix;
24047      }
24048      // Populate the "super-repo vs. with-submodules" commit-count display next to project size,
24049      // plus the current-branch chip that sits between project size and the commit counts.
24050      function updateCommitCounts(d) {
24051        var bBox = document.getElementById('git-branch-box');
24052        var bName = document.getElementById('git-branch-name');
24053        if (bBox) {
24054          if (d && d.is_git && d.branch) {
24055            if (bName) bName.textContent = d.branch;
24056            bBox.classList.remove('hidden');
24057          } else {
24058            bBox.classList.add('hidden');
24059          }
24060        }
24061        var box = document.getElementById('commit-counts');
24062        if (!box) return;
24063        if (!d || !d.is_git || !d.commit_count) { box.className = 'commit-counts hidden'; return; }
24064        var sup = document.getElementById('cc-super');
24065        var comb = document.getElementById('cc-combined');
24066        var combItem = document.getElementById('cc-combined-item');
24067        if (sup) sup.textContent = estFmt(d.commit_count);
24068        if (comb) comb.textContent = estFmt(d.combined_commit_count || d.commit_count);
24069        // The combined column is only meaningful when the repo actually has submodules.
24070        if (combItem) combItem.style.display = (d.submodule_count > 0) ? '' : 'none';
24071        box.className = 'commit-counts';
24072      }
24073      function updateAttribEstimate(path) {
24074        var sel = document.getElementById('attribution');
24075        if (!document.getElementById('attrib-estimate') || !sel) return;
24076        if (!path) { lastAttribEstimate = null; renderAttribBanner(); updateReviewAttribWarn(); updateCommitCounts(null); return; }
24077        var myGen = ++_attribEstGen;
24078        fetch('/api/attribution-estimate?path=' + encodeURIComponent(path))
24079          .then(function(r) { return r.json(); })
24080          .then(function(d) {
24081            if (myGen !== _attribEstGen) return; // a newer path won the race
24082            lastAttribEstimate = d;
24083            // Auto-default the toggle once (unless the user already chose): off only when heavy.
24084            if (d && d.is_git && d.blameable_files && !attributionTouched) {
24085              sel.value = (d.severity === 'heavy') ? 'disabled' : 'enabled';
24086            }
24087            renderAttribBanner();
24088            updateReviewAttribWarn();
24089            updateCommitCounts(d);
24090          })
24091          .catch(function() { if (myGen === _attribEstGen) { lastAttribEstimate = null; renderAttribBanner(); updateReviewAttribWarn(); updateCommitCounts(null); } });
24092      }
24093
24094      function loadPreview() {
24095        if (!previewPanel || !pathInput) return;
24096        // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
24097        multiRepoBlocked = false;
24098        refreshStep1Gate();
24099        if (GIT_MODE) {
24100          previewPanel.innerHTML = '<div class="preview-error sx-f3d78751" >Preview is not available for remote git refs. The scan will check out the source at runtime.</div>';
24101          setPreviewLoading(false);
24102          return;
24103        }
24104        var path = pathInput.value.trim();
24105        var zeroWarn = document.getElementById('zero-files-warning');
24106        if (!path) {
24107          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
24108          if (zeroWarn) zeroWarn.style.display = 'none';
24109          setPreviewLoading(false);
24110          return;
24111        }
24112        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
24113        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
24114        if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
24115        if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
24116        var myGen = ++_previewGen;
24117        var _prevMsgs = [
24118          'Scanning directory structure\u2026',
24119          'Detecting file types\u2026',
24120          'Applying include / exclude filters\u2026',
24121          'Estimating file counts\u2026',
24122          'Building scope preview\u2026',
24123          'Almost there\u2026'
24124        ];
24125        var _prevMsgIdx = 0;
24126        var _prevStart = Date.now();
24127        previewPanel.innerHTML =
24128          '<div class="preview-loading">' +
24129          '<div class="preview-spinner"></div>' +
24130          '<div class="preview-loading-text">' +
24131          '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
24132          '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
24133          '</div></div>';
24134        var _sizeTextEl = document.getElementById('project-size-text');
24135        if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
24136        window._previewInterval = setInterval(function() {
24137          if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
24138          _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
24139          var ml = document.getElementById('plm');
24140          if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
24141        }, 1500);
24142        window._previewElapsedTimer = setInterval(function() {
24143          if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
24144          var el = document.getElementById('ple');
24145          if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
24146        }, 1000);
24147        setPreviewLoading(true);
24148        // Kick off the (independent) attribution cost estimate for this path in parallel with the
24149        // scope preview; it auto-tunes the Code-ownership default on very large repos.
24150        updateAttribEstimate(path);
24151        var previewUrl = "/preview?path=" + encodeURIComponent(path)
24152          + "&include_globs=" + encodeURIComponent(includeValue)
24153          + "&exclude_globs=" + encodeURIComponent(excludeValue);
24154        fetch(previewUrl)
24155          .then(function (response) { return response.text(); })
24156          .then(function (html) {
24157            if (myGen !== _previewGen) return;
24158            clearInterval(window._previewInterval); window._previewInterval = null;
24159            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
24160            setPreviewLoading(false);
24161            previewPanel.innerHTML = html;
24162            attachPreviewInteractions();
24163            syncPythonVisibility();
24164            updateReview();
24165            setTimeout(collapseLanguagePills, 50);
24166            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
24167            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
24168            var sizeText = document.getElementById('project-size-text');
24169            var sizeBtn = document.getElementById('project-size-btn');
24170            // In server mode with upload sizes available, keep the compressed/original pair.
24171            if (SERVER_MODE && window._lastUploadSizes) {
24172              var us = window._lastUploadSizes;
24173              if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
24174                ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
24175              if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
24176                ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
24177            } else if (sizeText && projectSize) {
24178              sizeText.textContent = 'Project size: ' + projectSize;
24179              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
24180            } else if (sizeText) {
24181              sizeText.textContent = 'Project size: \u2014';
24182            }
24183            if (zeroWarn) {
24184              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
24185              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
24186              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
24187              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
24188              if (supportedCount === 0 && fileCount > 0) {
24189                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).';
24190                zeroWarn.style.display = '';
24191              } else {
24192                zeroWarn.style.display = 'none';
24193              }
24194            }
24195          })
24196          .catch(function (err) {
24197            if (myGen !== _previewGen) return;
24198            clearInterval(window._previewInterval); window._previewInterval = null;
24199            clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
24200            setPreviewLoading(false);
24201            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
24202          });
24203      }
24204
24205      function pickDirectory(targetInput, kind) {
24206        if (!targetInput) {
24207          showBannerToast("Directory picker: input element not found.", true);
24208          return;
24209        }
24210        if (SERVER_MODE) {
24211          if (kind === 'output') {
24212            showBannerToast(
24213              'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
24214              false,
24215              { top: true, icon: '\u{1F4C1}' }
24216            );
24217            return;
24218          }
24219          var inputEl = kind === 'coverage'
24220            ? document.getElementById('cov-upload-input')
24221            : document.getElementById('dir-upload-input');
24222          if (!inputEl) return;
24223          inputEl.onchange = function () {
24224            var files = inputEl.files;
24225            if (!files || files.length === 0) return;
24226            var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
24227            if (browseBtn) browseBtn.disabled = true;
24228
24229            function fileToBase64(file) {
24230              return new Promise(function (resolve, reject) {
24231                var reader = new FileReader();
24232                reader.onload = function () {
24233                  var b64 = reader.result.split(',')[1];
24234                  resolve(b64);
24235                };
24236                reader.onerror = reject;
24237                reader.readAsDataURL(file);
24238              });
24239            }
24240
24241            if (kind === 'coverage') {
24242              var f = files[0];
24243              if (previewPanel && targetInput === pathInput)
24244                previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
24245              fileToBase64(f).then(function (b64) {
24246                return fetch('/api/upload-file', {
24247                  method: 'POST',
24248                  headers: { 'Content-Type': 'application/json' },
24249                  body: JSON.stringify({ filename: f.name, content: b64 })
24250                }).then(function (r) { return r.json(); });
24251              })
24252                .then(function (d) {
24253                  if (d && d.tmp_path) {
24254                    if (coverageInput) coverageInput.value = d.tmp_path;
24255                    setCovStatus('idle');
24256                  } else if (d && d.error) { showBannerToast(d.error, true); }
24257                })
24258                .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
24259                .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
24260            } else {
24261              // ── Filter to source-code files only ─────────────────────────
24262              // Binary, generated, and dependency files (node_modules, .git,
24263              // build artifacts) are skipped so they are never uploaded.
24264              var CODE_EXTS = new Set([
24265                'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
24266                'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
24267                'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
24268                'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
24269                'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
24270                'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
24271                'tf','hcl','proto','thrift','avsc','graphql','gql'
24272              ]);
24273              var codeFiles = [];
24274              for (var i = 0; i < files.length; i++) {
24275                var f = files[i];
24276                var name = f.name;
24277                if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
24278                    name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
24279                  codeFiles.push(f); continue;
24280                }
24281                var dot = name.lastIndexOf('.');
24282                if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
24283              }
24284              // Collect specific .git metadata files for server-side git detection.
24285              // These have no source extension so they are excluded by the loop above,
24286              // but the server needs them to read branch/commit/author without running git.
24287              var gitMetaFiles = [];
24288              for (var i = 0; i < files.length; i++) {
24289                var f = files[i];
24290                var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
24291                var gitIdx = rp.indexOf('/.git/');
24292                if (gitIdx < 0) continue;
24293                var gitRel = rp.slice(gitIdx + 1);
24294                if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
24295                    gitRel === '.git/logs/HEAD' ||
24296                    gitRel.startsWith('.git/refs/heads/') ||
24297                    gitRel.startsWith('.git/refs/tags/')) {
24298                  gitMetaFiles.push(f);
24299                }
24300              }
24301              var uploadFiles = codeFiles.concat(gitMetaFiles);
24302              var total = files.length;
24303              var kept = codeFiles.length;
24304              if (kept === 0) {
24305                if (previewPanel && targetInput === pathInput)
24306                  previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
24307                if (browseBtn) browseBtn.disabled = false;
24308                inputEl.value = '';
24309                return;
24310              }
24311
24312              // ── Helper: apply upload result to UI ────────────────────────
24313              // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
24314              function applyUploadResult(tmpPath, sizes) {
24315                targetInput.value = tmpPath;
24316                scrollInputToEnd(targetInput);
24317                if (sizes && SERVER_MODE) {
24318                  window._lastUploadSizes = sizes;
24319                  // Immediately show both sizes before preview loads.
24320                  var sizeText = document.getElementById('project-size-text');
24321                  var sizeBtn = document.getElementById('project-size-btn');
24322                  if (sizeText) {
24323                    sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
24324                      ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
24325                  }
24326                  if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
24327                    ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
24328                }
24329                if (targetInput === pathInput) {
24330                  updateReportTitleFromPath();
24331                  autoSetOutputDir(tmpPath);
24332                  fetchProjectHistory(tmpPath);
24333                  loadPreview();
24334                  suggestCoverageFile(tmpPath);
24335                }
24336                updateReview();
24337                if (browseBtn) browseBtn.disabled = false;
24338                inputEl.value = '';
24339              }
24340
24341              // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
24342              if (typeof CompressionStream !== 'undefined') {
24343                if (previewPanel && targetInput === pathInput)
24344                  previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
24345
24346                // Build a minimal POSIX ustar tar header for a single file entry.
24347                function buildUstarHeader(filePath, fileSize) {
24348                  var BLOCK = 512;
24349                  var hdr = new Uint8Array(BLOCK);
24350                  var enc = new TextEncoder();
24351                  function wStr(off, len, s) {
24352                    var b = enc.encode(s);
24353                    for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
24354                  }
24355                  function wOct(off, len, val) {
24356                    var s = val.toString(8);
24357                    while (s.length < len - 1) s = '0' + s;
24358                    wStr(off, len, s + '\0');
24359                  }
24360                  // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
24361                  var name = filePath, prefix = '';
24362                  if (filePath.length > 99) {
24363                    var split = filePath.lastIndexOf('/', 154);
24364                    if (split > 0 && filePath.length - split - 1 <= 99) {
24365                      prefix = filePath.substring(0, split);
24366                      name   = filePath.substring(split + 1);
24367                    } else { name = filePath.substring(0, 99); }
24368                  }
24369                  wStr(0,   100, name);          // name
24370                  wOct(100,   8, 0o000644);      // mode
24371                  wOct(108,   8, 0);             // uid
24372                  wOct(116,   8, 0);             // gid
24373                  wOct(124,  12, fileSize);      // size
24374                  wOct(136,  12, 0);             // mtime (epoch)
24375                  for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
24376                  hdr[156] = 48;                 // type flag '0' = regular file
24377                  wStr(157, 100, '');            // linkname
24378                  wStr(257,   6, 'ustar');       // magic
24379                  wStr(263,   2, '00');          // version
24380                  wStr(265,  32, '');            // uname
24381                  wStr(297,  32, '');            // gname
24382                  wOct(329,   8, 0);             // devmajor
24383                  wOct(337,   8, 0);             // devminor
24384                  wStr(345, 155, prefix);        // prefix
24385                  // Compute checksum (sum of all bytes, placeholder = 32).
24386                  var chk = 0;
24387                  for (var i = 0; i < BLOCK; i++) chk += hdr[i];
24388                  var cs = chk.toString(8);
24389                  while (cs.length < 6) cs = '0' + cs;
24390                  wStr(148, 8, cs + '\0 ');
24391                  return hdr;
24392                }
24393
24394                // Build tar.gz one file at a time, piping through CompressionStream.
24395                // RAM usage = compressed output buffer + one file at a time.
24396                (async function () {
24397                  try {
24398                    var BLOCK = 512;
24399                    var cs     = new CompressionStream('gzip');
24400                    var writer = cs.writable.getWriter();
24401                    var chunks = [];
24402                    var reader = cs.readable.getReader();
24403                    var collecting = (async function () {
24404                      while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
24405                    })();
24406
24407                    for (var i = 0; i < uploadFiles.length; i++) {
24408                      var file = uploadFiles[i];
24409                      var path = file.webkitRelativePath || file.name;
24410                      var buf  = await file.arrayBuffer();
24411                      var data = new Uint8Array(buf);
24412                      // Header block
24413                      await writer.write(buildUstarHeader(path, data.length));
24414                      // Data padded to 512-byte boundary
24415                      if (data.length > 0) {
24416                        var padded = Math.ceil(data.length / BLOCK) * BLOCK;
24417                        var block  = new Uint8Array(padded);
24418                        block.set(data);
24419                        await writer.write(block);
24420                      }
24421                      if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
24422                        if (previewPanel && targetInput === pathInput)
24423                          previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
24424                      }
24425                    }
24426                    // End-of-archive: two 512-byte zero blocks
24427                    await writer.write(new Uint8Array(BLOCK * 2));
24428                    await writer.close();
24429                    await collecting;
24430
24431                    var blob = new Blob(chunks, { type: 'application/gzip' });
24432                    var sizeMB = (blob.size / 1048576).toFixed(1);
24433                    if (previewPanel && targetInput === pathInput)
24434                      previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
24435
24436                    var resp = await fetch('/api/upload-tarball', {
24437                      method: 'POST',
24438                      headers: { 'Content-Type': 'application/gzip' },
24439                      body: blob
24440                    });
24441                    var d = await resp.json();
24442                    if (d && d.tmp_path) {
24443                      applyUploadResult(d.tmp_path, {
24444                        compressed_bytes: d.compressed_bytes || 0,
24445                        original_bytes: d.original_bytes || 0
24446                      });
24447                    } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
24448                  } catch (e) {
24449                    showBannerToast('Upload failed: ' + String(e), true);
24450                    if (browseBtn) browseBtn.disabled = false;
24451                    inputEl.value = '';
24452                  }
24453                })();
24454
24455              } else {
24456                // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
24457                // Used only on browsers that lack CompressionStream (pre-2023).
24458                var BATCH = 200;
24459                var batches = [];
24460                for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
24461                var totalBatches = batches.length;
24462                if (previewPanel && targetInput === pathInput)
24463                  previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
24464
24465                function sendBatch(idx, currentUploadId, lastTmpPath) {
24466                  if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
24467                  if (previewPanel && targetInput === pathInput && totalBatches > 1)
24468                    previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
24469                  Promise.all(batches[idx].map(function (file) {
24470                    return fileToBase64(file).then(function (b64) {
24471                      return { path: file.webkitRelativePath || file.name, content: b64 };
24472                    });
24473                  })).then(function (fileList) {
24474                    var body = { files: fileList };
24475                    if (currentUploadId) body.upload_id = currentUploadId;
24476                    return fetch('/api/upload-directory', {
24477                      method: 'POST', headers: { 'Content-Type': 'application/json' },
24478                      body: JSON.stringify(body)
24479                    }).then(function (r) { return r.json(); });
24480                  }).then(function (d) {
24481                    if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
24482                    else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
24483                  }).catch(function (e) {
24484                    showBannerToast('Upload failed: ' + String(e), true);
24485                    if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
24486                  });
24487                }
24488                sendBatch(0, null, '');
24489              }
24490            }
24491          };
24492          inputEl.click();
24493          return;
24494        }
24495
24496        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
24497        if (browseButton) browseButton.disabled = true;
24498
24499        if (previewPanel && targetInput === pathInput) {
24500          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
24501        }
24502
24503        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
24504          .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
24505          .then(function (data) {
24506            if (data && data.selected_path) {
24507              targetInput.value = data.selected_path;
24508              scrollInputToEnd(targetInput);
24509
24510              if (targetInput === pathInput) {
24511                updateReportTitleFromPath();
24512                autoSetOutputDir(data.selected_path);
24513                fetchProjectHistory(data.selected_path);
24514                loadPreview();
24515                suggestCoverageFile(data.selected_path);
24516              }
24517
24518              updateReview();
24519            } else if (targetInput === pathInput) {
24520              loadPreview();
24521            }
24522          })
24523          .catch(function () {
24524            window.alert("Directory picker request failed.");
24525            if (previewPanel && targetInput === pathInput) {
24526              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
24527            }
24528          })
24529          .finally(function () {
24530            if (browseButton) browseButton.disabled = false;
24531          });
24532      }
24533
24534      if (themeToggle) {
24535        themeToggle.addEventListener("click", function () {
24536          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
24537          applyTheme(nextTheme);
24538          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
24539        });
24540      }
24541
24542      stepButtons.forEach(function (button) {
24543        button.addEventListener("click", function () {
24544          var target = Number(button.getAttribute("data-step-target"));
24545          // Block jumping forward off step 1 while the preview / upload is running
24546          // or while a multi-repository selection is unacknowledged.
24547          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
24548          setStep(target);
24549        });
24550      });
24551
24552      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
24553        button.addEventListener("click", function () {
24554          var target = Number(button.getAttribute("data-step-target")) || 1;
24555          if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
24556          setStep(target);
24557        });
24558      });
24559
24560      // True when the project path is untouched from the bundled sample default.
24561      function isDefaultSamplePath() {
24562        return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
24563      }
24564
24565      var defaultPathOverlay = document.getElementById("default-path-overlay");
24566      function closeDefaultPathModal() {
24567        if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
24568      }
24569      function openDefaultPathModal() {
24570        if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
24571      }
24572
24573      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
24574        // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
24575        // that borrow the .next-step style class but carry no data-next target).
24576        if (!button.hasAttribute("data-next")) return;
24577        button.addEventListener("click", function () {
24578          // Guard step 1 → 2: block while the scope preview / upload is still running
24579          // or while a multi-repository selection is unacknowledged.
24580          if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
24581          // Guard step 1 → 2: warn when the project path is still the sample default.
24582          if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
24583            openDefaultPathModal();
24584            return;
24585          }
24586          updateReview();
24587          setStep(Number(button.getAttribute("data-next")));
24588        });
24589      });
24590
24591      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
24592        if (!button.hasAttribute("data-prev")) return;
24593        button.addEventListener("click", function () {
24594          setStep(Number(button.getAttribute("data-prev")));
24595        });
24596      });
24597
24598      // Default-sample-path confirmation modal wiring.
24599      var defaultPathProceed = document.getElementById("default-path-proceed");
24600      if (defaultPathProceed) {
24601        defaultPathProceed.addEventListener("click", function () {
24602          closeDefaultPathModal();
24603          updateReview();
24604          setStep(2);
24605        });
24606      }
24607      var defaultPathCancel = document.getElementById("default-path-cancel");
24608      if (defaultPathCancel) {
24609        defaultPathCancel.addEventListener("click", function () {
24610          closeDefaultPathModal();
24611          if (pathInput) { pathInput.focus(); pathInput.select(); }
24612        });
24613      }
24614      if (defaultPathOverlay) {
24615        defaultPathOverlay.addEventListener("click", function (e) {
24616          if (e.target === defaultPathOverlay) closeDefaultPathModal();
24617        });
24618      }
24619      document.addEventListener("keydown", function (e) {
24620        if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
24621          closeDefaultPathModal();
24622        }
24623      });
24624
24625      document.addEventListener("keydown", function (e) {
24626        var tag = (document.activeElement || {}).tagName || "";
24627        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
24628        if (e.altKey || e.ctrlKey || e.metaKey) return;
24629        if (e.key === "ArrowRight" && currentStep < 4) {
24630          if (currentStep === 1 && step1ForwardBlocked()) return;
24631          if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
24632          updateReview(); setStep(currentStep + 1);
24633        }
24634        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
24635      });
24636
24637      if (useSamplePath) {
24638        useSamplePath.addEventListener("click", function () {
24639          pathInput.value = "testing/fixtures/basic";
24640          updateReportTitleFromPath();
24641          autoSetOutputDir("testing/fixtures/basic");
24642          loadPreview();
24643          suggestCoverageFile("testing/fixtures/basic");
24644        });
24645      }
24646
24647      if (useDefaultOutput) {
24648        useDefaultOutput.addEventListener("click", function () {
24649          delete outputDirInput.dataset.userEdited;
24650          autoSetOutputDir(pathInput ? pathInput.value : "");
24651          updateReview();
24652        });
24653      }
24654
24655      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
24656      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
24657
24658      // ── Drag-and-drop directory upload (server mode only) ─────────────────
24659      // Dropping a folder onto the path field bypasses Chrome's
24660      // "Upload X files to this site?" confirmation dialog.
24661      async function readDirRecursively(dirEntry, basePath) {
24662        var reader = dirEntry.createReader();
24663        var all = [];
24664        for (;;) {
24665          var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
24666          if (!batch.length) break;
24667          for (var i = 0; i < batch.length; i++) all.push(batch[i]);
24668        }
24669        var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
24670        var out = [];
24671        for (var i = 0; i < all.length; i++) {
24672          var sub = all[i];
24673          if (sub.isFile) {
24674            var f = await new Promise(function(res) { sub.file(res); });
24675            out.push({ file: f, path: basePath + '/' + sub.name });
24676          } else if (sub.isDirectory && !SKIP.has(sub.name)) {
24677            var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
24678            for (var j = 0; j < nested.length; j++) out.push(nested[j]);
24679          }
24680        }
24681        return out;
24682      }
24683
24684      function setupPathDropZone() {
24685        if (!SERVER_MODE || !pathInput) return;
24686        var CODE_EXTS = new Set([
24687          'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
24688          'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
24689          'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
24690          'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
24691          'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
24692          'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
24693        ]);
24694        pathInput.addEventListener('dragover', function(e) {
24695          e.preventDefault();
24696          pathInput.classList.add('drag-over');
24697        });
24698        pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
24699        pathInput.addEventListener('drop', function(e) {
24700          e.preventDefault();
24701          pathInput.classList.remove('drag-over');
24702          var items = e.dataTransfer.items;
24703          if (!items || !items.length) return;
24704          var dirEntry = null;
24705          for (var i = 0; i < items.length; i++) {
24706            var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
24707            if (entry && entry.isDirectory) { dirEntry = entry; break; }
24708          }
24709          if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
24710          var btn = browsePath;
24711          if (btn) btn.disabled = true;
24712          if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
24713
24714          readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
24715            var total = allEntries.length;
24716            var codeEntries = allEntries.filter(function(e) {
24717              var n = e.file.name;
24718              if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
24719              var dot = n.lastIndexOf('.');
24720              return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
24721            });
24722            var kept = codeEntries.length;
24723            if (kept === 0) {
24724              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
24725              if (btn) btn.disabled = false; return;
24726            }
24727
24728            function finish(tmpPath, sizes) {
24729              pathInput.value = tmpPath;
24730              scrollInputToEnd(pathInput);
24731              if (sizes) {
24732                window._lastUploadSizes = sizes;
24733                var sizeText = document.getElementById('project-size-text');
24734                var sizeBtn = document.getElementById('project-size-btn');
24735                if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
24736                  ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
24737                if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
24738                  ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
24739              }
24740              updateReportTitleFromPath();
24741              autoSetOutputDir(tmpPath);
24742              fetchProjectHistory(tmpPath);
24743              loadPreview();
24744              suggestCoverageFile(tmpPath);
24745              updateReview();
24746              if (btn) btn.disabled = false;
24747            }
24748
24749            if (typeof CompressionStream === 'undefined') {
24750              showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
24751              if (btn) btn.disabled = false; return;
24752            }
24753
24754            try {
24755              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
24756              var BLOCK = 512;
24757              var cs = new CompressionStream('gzip');
24758              var wtr = cs.writable.getWriter();
24759              var chunks = [];
24760              var rdr = cs.readable.getReader();
24761              var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
24762
24763              function buildHdr(fp, sz) {
24764                var hdr = new Uint8Array(BLOCK);
24765                var enc = new TextEncoder();
24766                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]; }
24767                function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
24768                var nm = fp, pfx = '';
24769                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); } }
24770                wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
24771                for (var i = 148; i < 156; i++) hdr[i] = 32;
24772                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);
24773                var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
24774                var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
24775                return hdr;
24776              }
24777
24778              for (var i = 0; i < codeEntries.length; i++) {
24779                var ce = codeEntries[i];
24780                var buf = await ce.file.arrayBuffer();
24781                var data = new Uint8Array(buf);
24782                await wtr.write(buildHdr(ce.path, data.length));
24783                if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
24784                if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
24785                  if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
24786              }
24787              await wtr.write(new Uint8Array(BLOCK * 2));
24788              await wtr.close();
24789              await collecting;
24790
24791              var blob = new Blob(chunks, { type: 'application/gzip' });
24792              var sizeMB = (blob.size / 1048576).toFixed(1);
24793              if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
24794              var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
24795              var d = await resp.json();
24796              if (d && d.tmp_path) {
24797                finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
24798              } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
24799            } catch (err) {
24800              showBannerToast('Upload failed: ' + String(err), true);
24801              if (btn) btn.disabled = false;
24802            }
24803          }).catch(function(err) {
24804            showBannerToast('Could not read folder: ' + String(err), true);
24805            if (btn) btn.disabled = false;
24806          });
24807        });
24808      }
24809      setupPathDropZone();
24810      if (browseCoverage) {
24811        browseCoverage.addEventListener("click", function () {
24812          pickDirectory(coverageInput || pathInput, "coverage");
24813        });
24814      }
24815
24816      function setCovStatus(state, opts) {
24817        if (!covScanStatus) return;
24818        opts = opts || {};
24819        covScanStatus.className = "cov-scan-status cov-scan-" + state;
24820        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
24821        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>';
24822        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>';
24823        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>';
24824        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>';
24825        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
24826        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
24827        if (state === "scanning") {
24828          html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
24829        } else if (state === "found") {
24830          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
24831          html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
24832          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
24833          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
24834        } else if (state === "hint") {
24835          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
24836          html += '<div class="cov-scan-title">' + tb2 + ' project &mdash; no coverage report found yet</div>';
24837          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>';
24838        } else if (state === "none") {
24839          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
24840          html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info &middot; Cobertura\u00a0XML &middot; JaCoCo\u00a0XML &middot; coverage.py\u00a0JSON &middot; Istanbul\u00a0JSON</div>';
24841        }
24842        html += '</div></div>';
24843        covScanStatus.innerHTML = html;
24844        if (state === "found") {
24845          var useBtn = covScanStatus.querySelector(".cov-scan-use");
24846          if (useBtn) useBtn.addEventListener("click", function () {
24847            if (coverageInput) coverageInput.value = "";
24848            covAutoFilled = false;
24849            setCovStatus("idle");
24850          });
24851        }
24852      }
24853
24854      function suggestCoverageFile(projectPath) {
24855        if (!coverageInput || !covScanStatus) return;
24856        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
24857        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
24858        clearTimeout(coverageSuggestTimer);
24859        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
24860        setCovStatus("scanning");
24861        coverageSuggestTimer = setTimeout(function () {
24862          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
24863            .then(function (r) { return r.json(); })
24864            .then(function (d) {
24865              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
24866              if (!d) { setCovStatus("none"); return; }
24867              if (d.found) {
24868                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
24869                setCovStatus("found", { found: d.found, tool: d.tool });
24870              } else if (d.tool && d.hint) {
24871                setCovStatus("hint", { tool: d.tool, hint: d.hint });
24872              } else {
24873                setCovStatus("none");
24874              }
24875            })
24876            .catch(function () { setCovStatus("idle"); });
24877        }, 600);
24878      }
24879
24880      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
24881
24882      if (coverageInput) coverageInput.addEventListener("input", function () {
24883        covAutoFilled = false;
24884        if (!this.value.trim()) setCovStatus("idle");
24885      });
24886
24887      // ── Language pill overflow: collapse to "+N more" chip ─────────────
24888      function collapseLanguagePills() {
24889        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
24890        rows.forEach(function(row) {
24891          // Remove any previous overflow chip
24892          var prev = row.querySelector('.lang-overflow-chip');
24893          if (prev) prev.remove();
24894          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
24895          pills.forEach(function(p) { p.style.display = ''; });
24896          if (!pills.length) return;
24897
24898          // Measure after restoring all pills
24899          var containerRight = row.getBoundingClientRect().right;
24900          var hidden = [];
24901          for (var i = pills.length - 1; i >= 1; i--) {
24902            var rect = pills[i].getBoundingClientRect();
24903            if (rect.right > containerRight + 2) {
24904              hidden.unshift(pills[i]);
24905              pills[i].style.display = 'none';
24906            } else {
24907              break;
24908            }
24909          }
24910
24911          if (hidden.length) {
24912            var chip = document.createElement('button');
24913            chip.type = 'button';
24914            chip.className = 'language-pill lang-overflow-chip';
24915            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
24916            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
24917            row.appendChild(chip);
24918          }
24919        });
24920      }
24921
24922      // Run after preview loads (preview panel populates language pills)
24923      var _origLoadPreviewCb = window.__previewLoaded;
24924      document.addEventListener('previewLoaded', collapseLanguagePills);
24925      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
24926      setTimeout(collapseLanguagePills, 400);
24927
24928      // ── Project history & output dir auto-set ──────────────────────────
24929      var wsOutputRoot   = document.getElementById("ws-output-root");
24930      var wsScanCount    = document.getElementById("ws-scan-count");
24931      var wsLastScan     = document.getElementById("ws-last-scan");
24932      var historyBadge   = document.getElementById("path-history-badge");
24933      var historyTimer   = null;
24934
24935      var wsOutputLink = document.getElementById("ws-output-link");
24936      function syncStripOutputRoot() {
24937        var val = outputDirInput ? outputDirInput.value : "";
24938        var display = val || "project/sloc";
24939        if (wsOutputRoot) wsOutputRoot.textContent = display;
24940        if (wsOutputLink) wsOutputLink.dataset.folder = val;
24941      }
24942
24943      function scrollInputToEnd(input) {
24944        if (!input) return;
24945        // Defer so the DOM has the new value before we measure scroll width.
24946        requestAnimationFrame(function () {
24947          input.scrollLeft = input.scrollWidth;
24948          input.selectionStart = input.selectionEnd = input.value.length;
24949        });
24950      }
24951
24952      function autoSetOutputDir(projectPath) {
24953        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
24954        if (GIT_MODE && GIT_OUTPUT_DIR) {
24955          outputDirInput.value = GIT_OUTPUT_DIR;
24956          scrollInputToEnd(outputDirInput);
24957          syncStripOutputRoot();
24958          updateReview();
24959          return;
24960        }
24961        if (!projectPath || !projectPath.trim()) return;
24962        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
24963        outputDirInput.value = cleaned + "/sloc";
24964        scrollInputToEnd(outputDirInput);
24965        syncStripOutputRoot();
24966        updateReview();
24967      }
24968
24969      var wsBranch = document.getElementById("ws-branch");
24970
24971      function fetchProjectHistory(projectPath) {
24972        if (!projectPath || !projectPath.trim()) {
24973          if (wsScanCount) wsScanCount.textContent = "\u2014";
24974          if (wsLastScan)  wsLastScan.textContent  = "\u2014";
24975          if (wsBranch)    wsBranch.textContent    = "\u2014";
24976          if (historyBadge) historyBadge.style.display = "none";
24977          return;
24978        }
24979        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
24980          .then(function (r) { return r.ok ? r.json() : null; })
24981          .then(function (data) {
24982            if (!data) return;
24983            var countStr = data.scan_count > 0
24984              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
24985              : "never";
24986            var tsStr = data.last_scan_timestamp
24987              ? data.last_scan_timestamp.replace(" UTC","")
24988              : "\u2014";
24989            if (wsScanCount) wsScanCount.textContent = countStr;
24990            if (wsLastScan)  wsLastScan.textContent  = tsStr;
24991            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "\u2014";
24992            if (data.scan_count > 0) {
24993              if (historyBadge) {
24994                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
24995                historyBadge.textContent = data.scan_count + " previous scan" +
24996                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
24997                  "Last: " + (data.last_scan_timestamp || "\u2014") +
24998                  " \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.";
24999                historyBadge.className = "path-history-badge found";
25000                historyBadge.style.display = "";
25001              }
25002            } else {
25003              if (historyBadge) historyBadge.style.display = "none";
25004            }
25005          })
25006          .catch(function () {});
25007      }
25008
25009      function onPathChange() {
25010        var val = pathInput ? pathInput.value : "";
25011        // Discard stale upload sizes when the user edits the path manually.
25012        window._lastUploadSizes = null;
25013        updateReportTitleFromPath();
25014        autoSetOutputDir(val);
25015        updateSidebarSummary();
25016        clearTimeout(historyTimer);
25017        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
25018        if (previewTimer) clearTimeout(previewTimer);
25019        previewTimer = setTimeout(loadPreview, 280);
25020        suggestCoverageFile(val);
25021      }
25022
25023      if (pathInput) {
25024        pathInput.addEventListener("input", onPathChange);
25025      }
25026
25027      if (outputDirInput) {
25028        outputDirInput.addEventListener("input", function () {
25029          outputDirInput.dataset.userEdited = "1";
25030          syncStripOutputRoot();
25031          updateReview();
25032        });
25033      }
25034
25035      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
25036        if (!node) return;
25037        node.addEventListener("input", function () {
25038          updateReview();
25039          if (previewTimer) clearTimeout(previewTimer);
25040          previewTimer = setTimeout(loadPreview, 280);
25041        });
25042      });
25043
25044      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
25045        var node = document.getElementById(id);
25046        if (node) node.addEventListener("change", updateReview);
25047      });
25048
25049      if (reportTitleInput) {
25050        reportTitleInput.addEventListener("input", function () {
25051          reportTitleTouched = reportTitleInput.value.trim().length > 0;
25052          updateReportTitleFromPath();
25053          updateReview();
25054        });
25055      }
25056
25057      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
25058      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
25059      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
25060      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
25061
25062      if (coverageInput) {
25063        coverageInput.addEventListener("input", function () {
25064          if (coverageInput.value.trim()) setCovStatus("idle");
25065        });
25066      }
25067
25068      if (form && loading && submitButton) {
25069        form.addEventListener("submit", function (e) {
25070          e.preventDefault();
25071          submitButton.disabled = true;
25072          submitButton.textContent = "Scanning...";
25073          startAsyncAnalysis(new FormData(form));
25074        });
25075      }
25076
25077      function openPath(folder) {
25078        if (!folder) return;
25079        fetch('/open-path?path=' + encodeURIComponent(folder))
25080          .then(function (r) { return r.json(); })
25081          .then(function (d) {
25082            if (d && d.server_mode_disabled)
25083              showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
25084          })
25085          .catch(function () {});
25086      }
25087
25088      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
25089        btn.addEventListener('click', function () {
25090          openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
25091        });
25092      });
25093
25094      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
25095      if (wsOutputLink) {
25096        wsOutputLink.addEventListener('click', function () {
25097          openPath(wsOutputLink.dataset.folder || '');
25098        });
25099      }
25100
25101      loadSavedTheme();
25102      updateMixedPolicyUI();
25103      updatePythonDocstringUI();
25104      applyScanPreset();
25105      updatePresetDescriptions();
25106      applyArtifactPreset();
25107      updateReview();
25108      updateScrollProgress(); // initialise bar to 0% (step 1)
25109      window.addEventListener("scroll", updateScrollProgress, { passive: true });
25110      onPathChange();         // seed output dir, history badge, and preview from initial path
25111      updateStepNav(1);
25112
25113      // Restore step from URL hash on initial load (e.g., back-forward cache)
25114      (function() {
25115        var hashMatch = location.hash.match(/^#step([1-4])$/);
25116        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
25117      })();
25118
25119      (function randomizeWatermarks() {
25120        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25121        if (!wms.length) return;
25122        var placed = [];
25123        function tooClose(top, left) {
25124          for (var i = 0; i < placed.length; i++) {
25125            var dt = Math.abs(placed[i][0] - top);
25126            var dl = Math.abs(placed[i][1] - left);
25127            if (dt < 16 && dl < 12) return true;
25128          }
25129          return false;
25130        }
25131        function pick(leftBand) {
25132          for (var attempt = 0; attempt < 50; attempt++) {
25133            var top = Math.random() * 88 + 2;
25134            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25135            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25136          }
25137          var top = Math.random() * 88 + 2;
25138          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25139          placed.push([top, left]);
25140          return [top, left];
25141        }
25142        var half = Math.floor(wms.length / 2);
25143        wms.forEach(function (img, i) {
25144          var pos = pick(i < half);
25145          var size = Math.floor(Math.random() * 80 + 110);
25146          var rot = (Math.random() * 360).toFixed(1);
25147          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
25148          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;
25149        });
25150      })();
25151
25152      (function spawnCodeParticles() {
25153        var container = document.getElementById('code-particles');
25154        if (!container) return;
25155        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
25156        for (var i = 0; i < 44; i++) {
25157          (function(idx) {
25158            var el = document.createElement('span');
25159            el.className = 'code-particle';
25160            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
25161            var left = Math.random() * 94 + 2;
25162            var top = Math.random() * 88 + 6;
25163            var dur = (Math.random() * 10 + 9).toFixed(1);
25164            var delay = (Math.random() * 18).toFixed(1);
25165            var rot = (Math.random() * 26 - 13).toFixed(1);
25166            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
25167            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';
25168            container.appendChild(el);
25169          })(i);
25170        }
25171      })();
25172    })();
25173  </script>
25174  <script nonce="{{ csp_nonce }}">
25175    (function () {
25176      var raw = {{ prefill_json|safe }};
25177      if (!raw || typeof raw !== 'object' || !raw.path) return;
25178      function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
25179      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
25180      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
25181      setVal('path', raw.path || '');
25182      setVal('include_globs', raw.include_globs || '');
25183      setVal('exclude_globs', raw.exclude_globs || '');
25184      setVal('output_dir', raw.output_dir || '');
25185      setVal('report_title', raw.report_title || '');
25186      if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
25187      setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
25188      setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
25189      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
25190      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
25191      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
25192      if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
25193      setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
25194      setChecked('generate_html', raw.generate_html !== false);
25195      setChecked('generate_pdf', !!raw.generate_pdf);
25196      if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
25197      if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
25198      setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
25199      setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
25200      if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
25201      if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
25202      if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
25203      if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
25204      if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
25205      if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
25206      if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
25207      setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
25208      setSelect('attribution', raw.attribution ? 'enabled' : 'disabled');
25209      // Trigger dynamic UI updates after pre-fill.
25210      setTimeout(function () {
25211        var pathEl = document.getElementById('path');
25212        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
25213        var policyEl = document.getElementById('mixed_line_policy');
25214        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
25215      }, 80);
25216    })();
25217  </script>
25218  <script nonce="{{ csp_nonce }}">
25219  (function(){
25220    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'}];
25221    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);});}
25222    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25223    function init(){
25224      var btn=document.getElementById('settings-btn');if(!btn)return;
25225      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25226      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
25227      document.body.appendChild(m);
25228      var g=document.getElementById('scheme-grid');
25229      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);});
25230      var cl=document.getElementById('settings-close');
25231      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);});})();
25232      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');});
25233      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25234      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25235    }
25236    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25237  }());
25238  </script>
25239  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
25240    <div class="wb-ftip-arrow"></div>
25241    <span id="wb-ftip-text"></span>
25242  </div>
25243  <script nonce="{{ csp_nonce }}">(function(){
25244    var tip=document.getElementById('wb-ftip');
25245    var txt=document.getElementById('wb-ftip-text');
25246    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
25247    if(!tip||!txt)return;
25248    function pos(el){
25249      var r=el.getBoundingClientRect();
25250      tip.style.display='block';
25251      var tw=tip.offsetWidth;
25252      var lx=r.left+r.width/2-tw/2;
25253      if(lx<8)lx=8;
25254      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
25255      tip.style.left=lx+'px';
25256      tip.style.top=(r.bottom+8)+'px';
25257      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';}
25258    }
25259    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
25260      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
25261      el.addEventListener('mouseleave',function(){tip.style.display='none';});
25262    });
25263    window.addEventListener('blur',function(){tip.style.display='none';});
25264    document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
25265  })();
25266  (function(){
25267    function fixArtifactHintSpacing(){
25268      var grid=document.querySelector('.artifact-grid');
25269      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
25270    }
25271    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
25272  }());
25273  (function(){
25274    var dot=document.getElementById('status-dot');
25275    var pingEl=document.getElementById('server-ping-ms');
25276    var tipEl=document.getElementById('server-tip-ping');
25277    var fm=document.getElementById('footer-mode');
25278    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)';}}
25279    function doPing(){
25280      var t0=performance.now();
25281      fetch('/healthz',{cache:'no-store'})
25282        .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);})
25283        .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)';}});
25284    }
25285    doPing();
25286    setInterval(doPing,5000);
25287    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');}
25288  })();
25289  </script>
25290  <span class="sx-dab0f2f8" id="page-bottom" aria-hidden="true" ></span>
25291  <footer class="site-footer">
25292    local code analysis - metrics, history and reports
25293    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
25294    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25295    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25296    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25297    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25298  </footer>
25299</body>
25300</html>
25301"##,
25302    ext = "html"
25303)]
25304struct IndexTemplate {
25305    version: &'static str,
25306    prefill_json: String,
25307    csp_nonce: String,
25308    git_repo: String,
25309    git_ref: String,
25310    git_label_json: String,
25311    git_output_dir_json: String,
25312    server_mode: bool,
25313}
25314
25315// ── SplashTemplate ────────────────────────────────────────────────────────────
25316
25317#[derive(Template)]
25318#[template(
25319    source = r##"
25320<!doctype html>
25321<html lang="en">
25322<head>
25323  <meta charset="utf-8">
25324  <meta name="viewport" content="width=device-width, initial-scale=1">
25325  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
25326  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25327  <link rel="stylesheet" href="/static/app.css">
25328  <script src="/static/app.js"></script>
25329  <script type="application/ld+json">
25330  {
25331    "@context": "https://schema.org",
25332    "@type": "SoftwareApplication",
25333    "name": "oxide-sloc",
25334    "applicationCategory": "DeveloperApplication",
25335    "operatingSystem": "Windows, Linux",
25336    "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.",
25337    "softwareVersion": "{{ version }}",
25338    "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
25339    "license": "https://www.gnu.org/licenses/agpl-3.0.html",
25340    "url": "https://github.com/oxide-sloc/oxide-sloc",
25341    "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
25342    "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",
25343    "programmingLanguage": "Rust",
25344    "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
25345  }
25346  </script>
25347  <style nonce="{{ csp_nonce }}">
25348    :root {
25349      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25350      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25351      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
25352      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25353      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
25354    }
25355    body.dark-theme {
25356      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
25357      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
25358    }
25359    *{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;}
25360    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25361    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25362    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25363    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
25364    @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));}}
25365    .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);}
25366    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25367    .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));}
25368    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25369    .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;}
25370    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25371    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25372    @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; } }
25373    .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;}
25374    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25375    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
25376    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25377    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25378    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25379    .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;}
25380    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25381    .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);}
25382    .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;}
25383    .settings-close:hover{color:var(--text);background:var(--surface-2);}
25384    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25385    .settings-modal-body{padding:14px 16px 16px;}
25386    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25387    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25388    .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;}
25389    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25390    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25391    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25392    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25393    .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;}
25394    .tz-select:focus{border-color:var(--oxide);}
25395    .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;}
25396    .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;}
25397    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
25398    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25399    .hero{text-align:center;margin:0 auto 18px;}
25400    .hero-logo-wrap{display:inline-block;cursor:default;}
25401    .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;}
25402    .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;}
25403    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
25404    .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;}
25405    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%);}
25406    .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;
25407      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
25408      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
25409      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;}
25410    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
25411    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
25412    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;}
25413    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
25414    .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;}
25415    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
25416    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
25417    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
25418    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
25419    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
25420    .card-section-grid-4{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;}
25421    @media(max-width:1150px){.card-section-grid-4{grid-template-columns:1fr 1fr;}}
25422    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3,.card-section-grid-4{grid-template-columns:1fr 1fr;}}
25423    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3,.card-section-grid-4{grid-template-columns:1fr;}}
25424    .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;}
25425    .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;}
25426    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
25427    @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
25428    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
25429    .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);}
25430    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
25431    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
25432    .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);}
25433    .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);}
25434    .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);}
25435    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
25436    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
25437    .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;}
25438    body.dark-theme .action-card-cta{color:var(--oxide);}
25439    .action-card.view .action-card-cta{color:var(--accent-2);}
25440    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
25441    .action-card.compare .action-card-cta{color:#7c3aed;}
25442    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
25443    .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);}
25444    .action-card.git-tools .action-card-cta{color:#15803d;}
25445    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
25446    .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);}
25447    .action-card.trend .action-card-cta{color:#0e7490;}
25448    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
25449    .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);}
25450    .action-card.automation .action-card-cta{color:#b45309;}
25451    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
25452    .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);}
25453    .action-card.test-metrics .action-card-cta{color:#be185d;}
25454    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
25455    .action-card.ownership .action-card-icon{background:linear-gradient(135deg,#6366f1,#4338ca);color:#fff;box-shadow:0 8px 22px rgba(99,102,241,0.28);}
25456    .action-card.ownership .action-card-cta{color:#4338ca;}
25457    body.dark-theme .action-card.ownership .action-card-cta{color:#a5b4fc;}
25458    .action-card:hover .action-card-cta{gap:12px;}
25459    .action-card.card-split{flex-direction:row;align-items:stretch;}
25460    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
25461    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
25462    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
25463    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
25464    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
25465    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
25466    .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;}
25467    .ac-badge.active{opacity:1;}
25468    .ac-badge.github{border-color:#555;color:#555;}
25469    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
25470    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
25471    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
25472    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
25473    body.dark-theme .ac-right-row{color:var(--muted);}
25474    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
25475    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
25476    .divider{height:1px;background:var(--line);margin:32px 0;}
25477    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
25478    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
25479    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
25480    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
25481      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
25482    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
25483    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
25484    body.dark-theme .info-chip-val{color:var(--oxide);}
25485    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
25486    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
25487      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
25488      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
25489    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
25490      border:6px solid transparent;border-top-color:var(--text);}
25491    .info-chip:hover .info-chip-tip{display:block;}
25492    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
25493    .chip-slide.fading{filter:blur(5px);opacity:0;}
25494    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25495    .site-footer a{color:var(--muted);}
25496    .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;}
25497    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
25498    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
25499    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
25500    .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;}
25501    .lan-badge.local{background:var(--oxide-2);}
25502    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
25503    .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);}
25504    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
25505    .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;}
25506    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
25507    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
25508    .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;}
25509    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
25510    .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;}
25511    .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);}
25512    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
25513    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
25514    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
25515    .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;}
25516    @media (max-height: 1100px) {
25517      .page{padding-top:10px;}
25518      .hero{margin-bottom:10px;}
25519      .hero-logo{width:54px;height:60px;}
25520      .hero-logo-shadow{width:42px;}
25521      .hero-title{font-size:28px;}
25522      .hero-subtitle{font-size:13px;}
25523      .card-sections{gap:12px;margin-bottom:6px;}
25524      .card-section-grid-2,.card-section-grid-3{gap:10px;}
25525      .action-card{padding:8px 15px 8px;}
25526      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
25527      .action-card-icon svg{width:18px;height:18px;}
25528      .action-card-title{font-size:13px;}
25529      .action-card-desc{font-size:11px;margin-bottom:6px;}
25530      .action-card-cta{font-size:11px;}
25531      .ac-right-row{font-size:11px;}
25532      .divider{margin:14px 0;}
25533      .info-strip{gap:7px;margin-bottom:8px;}
25534      .info-chip{padding:7px 10px;}
25535      .info-chip-val{font-size:13px;}
25536      .info-chip-label{font-size:9px;}
25537      .site-footer{padding:8px 24px;font-size:12px;}
25538      .lan-local-hint{margin-top:8px;}
25539    }
25540    @media (max-height: 850px) {
25541      .page{padding-top:6px;}
25542      .hero{margin-bottom:6px;}
25543      .hero-logo{width:42px;height:46px;}
25544      .hero-title{font-size:22px;}
25545      .hero-subtitle{font-size:12px;}
25546      .card-sections{gap:10px;}
25547      .action-card-desc{margin-bottom:4px;}
25548      .divider{margin:8px 0;}
25549      .info-strip{margin-bottom:6px;}
25550      .lan-local-hint{margin-top:10px;}
25551    }
25552  </style>
25553</head>
25554<body>
25555  <div class="background-watermarks" aria-hidden="true">
25556    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25557    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25558    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25559    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25560    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25561    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25562    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25563  </div>
25564  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25565  <div class="top-nav">
25566    <div class="top-nav-inner">
25567      <a class="brand" href="/">
25568        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
25569        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
25570      </a>
25571      <div class="nav-right">
25572        <a class="nav-pill sx-8c38ef73" href="/" >Home</a>
25573        <div class="nav-dropdown">
25574          <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>
25575          <div class="nav-dropdown-menu">
25576            <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>
25577          </div>
25578        </div>
25579        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25580        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25581        <div class="nav-dropdown">
25582          <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>
25583          <div class="nav-dropdown-menu">
25584            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
25585            <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>
25586          </div>
25587        </div>
25588        <div class="server-status-wrap" id="server-status-wrap">
25589          <div class="nav-pill server-online-pill" id="server-status-pill">
25590            <span class="status-dot" id="status-dot"></span>
25591            <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
25592            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
25593          </div>
25594          <div class="server-status-tip">
25595            {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
25596            <span class="sx-238af6bc" id="server-tip-ping" ></span>
25597          </div>
25598        </div>
25599        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25600          <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>
25601        </button>
25602        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25603          <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>
25604          <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>
25605        </button>
25606      </div>
25607    </div>
25608  </div>
25609
25610  <div class="page">
25611    <div class="hero">
25612      <div class="hero-logo-wrap" id="hero-logo-wrap">
25613        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
25614      </div>
25615      <div class="hero-logo-shadow"></div>
25616      <div class="hero-title-wrap">
25617        <div class="hero-title-aura" aria-hidden="true"></div>
25618        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
25619      </div>
25620      <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>
25621    </div>
25622
25623    <div class="card-sections">
25624
25625      <div>
25626        <div class="card-section-label">Analysis</div>
25627        <div class="card-section-grid-2">
25628          <a class="action-card scan card-split" href="/scan-setup">
25629            <div class="action-card-left">
25630              <div class="action-card-icon">
25631                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
25632              </div>
25633              <div class="action-card-title">Scan Project</div>
25634              <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>
25635              <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>
25636            </div>
25637            <div class="action-card-sep"></div>
25638            <div class="action-card-right">
25639              <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>
25640              <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>
25641              <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>
25642              <div class="ac-right-stat" id="acp-scan-stat"></div>
25643            </div>
25644          </a>
25645          <a class="action-card test-metrics card-split" href="/test-metrics">
25646            <div class="action-card-left">
25647              <div class="action-card-icon">
25648                <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>
25649              </div>
25650              <div class="action-card-title">Test Metrics</div>
25651              <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>
25652              <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>
25653            </div>
25654            <div class="action-card-sep"></div>
25655            <div class="action-card-right">
25656              <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>
25657              <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>
25658              <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>
25659              <div class="ac-right-stat" id="acp-test-stat"></div>
25660            </div>
25661          </a>
25662        </div>
25663      </div>
25664
25665      <div>
25666        <div class="card-section-label">Reports &amp; Insights</div>
25667        <div class="card-section-grid-4">
25668          <a class="action-card view" href="/view-reports">
25669            <div class="action-card-icon">
25670              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
25671            </div>
25672            <div class="action-card-title">View Reports</div>
25673            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
25674            <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>
25675          </a>
25676          <a class="action-card compare" href="/compare-scans">
25677            <div class="action-card-icon">
25678              <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>
25679            </div>
25680            <div class="action-card-title">Compare Scans</div>
25681            <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>
25682            <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>
25683          </a>
25684          <a class="action-card trend" href="/trend-reports">
25685            <div class="action-card-icon">
25686              <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>
25687            </div>
25688            <div class="action-card-title">Trend Report</div>
25689            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
25690            <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>
25691          </a>
25692          <a class="action-card ownership" href="/code-ownership">
25693            <div class="action-card-icon">
25694              <svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
25695            </div>
25696            <div class="action-card-title">Code Ownership</div>
25697            <p class="action-card-desc">Attribute every line to its author via git blame — per-author breakdowns, a contributor leaderboard, and hotspot ownership.</p>
25698            <span class="action-card-cta">View ownership <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>
25699          </a>
25700        </div>
25701      </div>
25702
25703      <div>
25704        <div class="card-section-label">Developer Tools</div>
25705        <div class="card-section-grid-2">
25706          <a class="action-card git-tools card-split" href="/git-browser">
25707            <div class="action-card-left">
25708              <div class="action-card-icon">
25709                <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>
25710              </div>
25711              <div class="action-card-title">Git Browser</div>
25712              <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>
25713              <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>
25714            </div>
25715            <div class="action-card-sep"></div>
25716            <div class="action-card-right">
25717              <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>
25718              <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>
25719              <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>
25720            </div>
25721          </a>
25722          <a class="action-card automation card-split" href="/integrations">
25723            <div class="action-card-left">
25724              <div class="action-card-icon">
25725                <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>
25726              </div>
25727              <div class="action-card-title">Integrations</div>
25728              <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>
25729              <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>
25730            </div>
25731            <div class="action-card-sep"></div>
25732            <div class="action-card-right">
25733              <div class="ac-badges-grid">
25734                <span class="ac-badge github"     id="acp-gh">GitHub</span>
25735                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
25736                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
25737                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
25738              </div>
25739              <div class="ac-right-stat" id="acp-int-stat"></div>
25740            </div>
25741          </a>
25742        </div>
25743      </div>
25744
25745    </div>
25746
25747    {% if server_mode %}
25748    <div class="lan-card server">
25749      <div class="lan-card-header">
25750        <span class="lan-badge">LAN server</span>
25751        Accessible on your network
25752      </div>
25753      {% if let Some(ip) = lan_ip %}
25754      <div class="lan-url-row">
25755        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
25756        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
25757          <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>
25758          Copy URL
25759        </button>
25760      </div>
25761      <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>
25762      {% if has_api_key %}
25763      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
25764      {% endif %}
25765      {% else %}
25766      <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>
25767      {% endif %}
25768    </div>
25769    {% endif %}
25770
25771    <div class="divider"></div>
25772
25773    <div class="info-strip">
25774      <div class="info-chip">
25775        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
25776        <div class="chip-slide">
25777          <div class="info-chip-val">60</div>
25778          <div class="info-chip-label">Languages</div>
25779        </div>
25780      </div>
25781      <div class="info-chip">
25782        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
25783        <div class="chip-slide">
25784          <div class="info-chip-val">100%</div>
25785          <div class="info-chip-label">Self-contained</div>
25786        </div>
25787      </div>
25788      <div class="info-chip">
25789        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
25790        <div class="chip-slide">
25791          <div class="info-chip-val">HTML+PDF</div>
25792          <div class="info-chip-label">Exportable reports</div>
25793        </div>
25794      </div>
25795      <div class="info-chip">
25796        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
25797        <div class="chip-slide">
25798          <div class="info-chip-val">Webhook</div>
25799          <div class="info-chip-label">3 platforms</div>
25800        </div>
25801      </div>
25802      <div class="info-chip">
25803        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
25804        <div class="chip-slide">
25805          <div class="info-chip-val">IEEE</div>
25806          <div class="info-chip-label">1045-1992</div>
25807        </div>
25808      </div>
25809    </div>
25810
25811    {% if lan_ip.is_none() %}
25812    <div class="lan-local-hint">
25813      <strong>Want teammates on the same network to access this?</strong><br>
25814      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
25815    </div>
25816    {% endif %}
25817  </div>
25818
25819  <footer class="site-footer">
25820    local code analysis - metrics, history and reports
25821    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
25822    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25823    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25824    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25825    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
25826  </footer>
25827
25828  <script nonce="{{ csp_nonce }}">
25829    (function () {
25830      var storageKey = 'oxide-sloc-theme';
25831      var body = document.body;
25832      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
25833      var toggle = document.getElementById('theme-toggle');
25834      if (toggle) toggle.addEventListener('click', function () {
25835        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
25836        body.classList.toggle('dark-theme', next === 'dark');
25837        try { localStorage.setItem(storageKey, next); } catch(e) {}
25838      });
25839      var copyBtn = document.getElementById('lan-copy-btn');
25840      if (copyBtn) copyBtn.addEventListener('click', function() {
25841        var btn = this;
25842        var el = document.getElementById('lan-url-val');
25843        if (!el) return;
25844        var url = el.textContent.trim();
25845        if (navigator.clipboard) {
25846          navigator.clipboard.writeText(url).then(function() {
25847            var orig = btn.innerHTML;
25848            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!';
25849            setTimeout(function() { btn.innerHTML = orig; }, 1800);
25850          });
25851        }
25852      });
25853      (function randomizeWatermarks() {
25854        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25855        if (!wms.length) return;
25856        var placed = [];
25857        function tooClose(top, left) {
25858          for (var i = 0; i < placed.length; i++) {
25859            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
25860            if (dt < 16 && dl < 12) return true;
25861          }
25862          return false;
25863        }
25864        function pick(leftBand) {
25865          for (var attempt = 0; attempt < 50; attempt++) {
25866            var top = Math.random() * 88 + 2;
25867            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25868            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25869          }
25870          var top = Math.random() * 88 + 2;
25871          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25872          placed.push([top, left]); return [top, left];
25873        }
25874        var half = Math.floor(wms.length / 2);
25875        wms.forEach(function (img, i) {
25876          var pos = pick(i < half);
25877          var size = Math.floor(Math.random() * 100 + 120);
25878          var rot = (Math.random() * 360).toFixed(1);
25879          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
25880          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;
25881        });
25882      })();
25883
25884      (function spawnCodeParticles() {
25885        var container = document.getElementById('code-particles');
25886        if (!container) return;
25887        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
25888        var count = 44;
25889        for (var i = 0; i < count; i++) {
25890          (function(idx) {
25891            var el = document.createElement('span');
25892            el.className = 'code-particle';
25893            var text = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
25894            el.textContent = text;
25895            var left = Math.random() * 94 + 2;
25896            var top = Math.random() * 88 + 6;
25897            var dur = (Math.random() * 10 + 9).toFixed(1);
25898            var delay = (Math.random() * 18).toFixed(1);
25899            var rot = (Math.random() * 26 - 13).toFixed(1);
25900            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
25901            el.style.left = left.toFixed(1) + '%';
25902            el.style.top = top.toFixed(1) + '%';
25903            el.style.setProperty('--rot', rot + 'deg');
25904            el.style.setProperty('--op', op);
25905            el.style.animationDuration = dur + 's';
25906            el.style.animationDelay = '-' + delay + 's';
25907            container.appendChild(el);
25908          })(i);
25909        }
25910      })();
25911      (function heroAnimations() {
25912        var sub = document.getElementById('hero-subtitle');
25913        if (sub) {
25914          var full = sub.textContent.trim();
25915          sub.textContent = '';
25916          sub.style.opacity = '1';
25917          var cursor = document.createElement('span');
25918          cursor.className = 'hero-cursor';
25919          sub.appendChild(cursor);
25920          var i = 0;
25921          setTimeout(function() {
25922            var iv = setInterval(function() {
25923              if (i < full.length) {
25924                sub.insertBefore(document.createTextNode(full[i]), cursor);
25925                i++;
25926              } else {
25927                clearInterval(iv);
25928                setTimeout(function() {
25929                  cursor.style.transition = 'opacity 1s ease';
25930                  cursor.style.opacity = '0';
25931                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
25932                }, 2400);
25933              }
25934            }, 11);
25935          }, 374);
25936        }
25937      })();
25938      (function logoBob() {
25939        var logo = document.querySelector('.hero-logo');
25940        var shadow = document.querySelector('.hero-logo-shadow');
25941        if (!logo) return;
25942        var cycleStart = null, cycleDur = 3600;
25943        var peakY = -14, peakScale = 1.07, peakRot = 0;
25944        function newCycle() {
25945          cycleDur = 3000 + Math.random() * 1840;
25946          peakY = -(9 + Math.random() * 13.8);
25947          peakScale = 1.04 + Math.random() * 0.081;
25948          peakRot = (Math.random() * 11.5 - 5.75);
25949        }
25950        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
25951        newCycle();
25952        function frame(ts) {
25953          if (cycleStart === null) cycleStart = ts;
25954          var t = (ts - cycleStart) / cycleDur;
25955          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
25956          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
25957          var y = peakY * phase;
25958          var sc = 1 + (peakScale - 1) * phase;
25959          var rot = peakRot * Math.sin(Math.PI * phase);
25960          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
25961          if (shadow) {
25962            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
25963            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
25964          }
25965          requestAnimationFrame(frame);
25966        }
25967        requestAnimationFrame(frame);
25968      })();
25969      (function mouseEffects() {
25970        var heroTitle = document.getElementById('hero-title');
25971        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
25972        function tick() {
25973          raf = null;
25974          if (heroTitle) {
25975            var r = heroTitle.getBoundingClientRect();
25976            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
25977            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
25978            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
25979          }
25980        }
25981        document.addEventListener('mousemove', function(e) {
25982          mx = e.clientX; my = e.clientY;
25983          if (!raf) raf = requestAnimationFrame(tick);
25984        });
25985        document.addEventListener('mouseleave', function() {
25986          if (heroTitle) {
25987            heroTitle.style.transition = 'transform 0.5s ease';
25988            heroTitle.style.transform = '';
25989            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
25990          }
25991        });
25992        document.querySelectorAll('.action-card').forEach(function(card) {
25993          card.addEventListener('mousemove', function(e) {
25994            var rect = card.getBoundingClientRect();
25995            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
25996            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
25997            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
25998            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
25999          });
26000          card.addEventListener('mouseleave', function() {
26001            card.style.transition = '';
26002            card.style.transform = '';
26003          });
26004        });
26005      })();
26006      (function chipSlideshow() {
26007        var slides = [
26008          [{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'}],
26009          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
26010          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
26011          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
26012          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
26013        ];
26014        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
26015        var indices = [0,0,0,0,0];
26016        var paused = [false,false,false,false,false];
26017        chips.forEach(function(chip, i) {
26018          chip.addEventListener('mouseenter', function() { paused[i] = true; });
26019          chip.addEventListener('mouseleave', function() { paused[i] = false; });
26020        });
26021        function advance(i) {
26022          if (paused[i]) return;
26023          var chip = chips[i];
26024          var inner = chip.querySelector('.chip-slide');
26025          if (!inner) return;
26026          inner.classList.add('fading');
26027          setTimeout(function() {
26028            indices[i] = (indices[i] + 1) % slides[i].length;
26029            var s = slides[i][indices[i]];
26030            chip.querySelector('.info-chip-val').textContent = s.v;
26031            chip.querySelector('.info-chip-label').textContent = s.l;
26032            inner.classList.remove('fading');
26033          }, 720);
26034        }
26035        setInterval(function() {
26036          chips.forEach(function(chip, i) { advance(i); });
26037        }, 6000);
26038      })();
26039      (function cardLiveData() {
26040        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
26041          var el = document.getElementById('acp-scan-stat');
26042          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
26043        }).catch(function(){});
26044        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
26045          var el = document.getElementById('acp-test-stat');
26046          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
26047        }).catch(function(){});
26048        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
26049          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
26050          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
26051          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
26052          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
26053          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
26054          var stat = document.getElementById('acp-int-stat');
26055          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
26056        }).catch(function(){});
26057        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
26058          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
26059        }).catch(function(){});
26060      })();
26061    })();
26062  </script>
26063  <script nonce="{{ csp_nonce }}">
26064  (function(){
26065    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'}];
26066    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);});}
26067    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26068    function init(){
26069      var btn=document.getElementById('settings-btn');if(!btn)return;
26070      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26071      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
26072      document.body.appendChild(m);
26073      var g=document.getElementById('scheme-grid');
26074      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);});
26075      var cl=document.getElementById('settings-close');
26076      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);});})();
26077      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');});
26078      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26079      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26080    }
26081    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26082  }());
26083  </script>
26084  <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>
26085</body>
26086</html>
26087"##,
26088    ext = "html"
26089)]
26090struct SplashTemplate {
26091    csp_nonce: String,
26092    server_mode: bool,
26093    lan_ip: Option<String>,
26094    port: u16,
26095    version: &'static str,
26096    has_api_key: bool,
26097}
26098
26099// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
26100
26101#[derive(Template)]
26102#[template(
26103    source = r##"
26104<!doctype html>
26105<html lang="en">
26106<head>
26107  <meta charset="utf-8">
26108  <meta name="viewport" content="width=device-width, initial-scale=1">
26109  <title>OxideSLOC — Start a Scan</title>
26110  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26111  <link rel="stylesheet" href="/static/app.css">
26112  <script src="/static/app.js"></script>
26113  <style nonce="{{ csp_nonce }}">
26114    :root {
26115      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
26116      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26117      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26118      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26119      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
26120    }
26121    body.dark-theme {
26122      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
26123      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
26124    }
26125    *{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;}
26126    .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);}
26127    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26128    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
26129    .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));}
26130    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
26131    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
26132    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26133    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26134    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26135    @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; } }
26136    .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;}
26137    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26138    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
26139    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26140    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26141    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26142    .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;}
26143    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26144    .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);}
26145    .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;}
26146    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26147    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26148    .settings-modal-body{padding:14px 16px 16px;}
26149    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26150    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26151    .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;}
26152    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26153    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26154    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26155    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26156    .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;}
26157    .tz-select:focus{border-color:var(--oxide);}
26158    .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
26159    .page-header{text-align:center;margin-bottom:16px;}
26160    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
26161    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
26162    /* Cards */
26163    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
26164    .option-card-wrap{position:relative;}
26165    .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;}
26166    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
26167    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
26168    @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
26169    .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;}
26170    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
26171    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
26172    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
26173    .card-top-row{display:flex;align-items:center;gap:20px;}
26174    /* Two-column layout inside each card */
26175    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
26176    .card-left{display:flex;align-items:flex-start;min-width:0;}
26177    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
26178    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
26179    .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);}
26180    .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);}
26181    .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);}
26182    .card-text{min-width:0;}
26183    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
26184    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
26185    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
26186    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
26187    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
26188    /* Right CTA column */
26189    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
26190    .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;}
26191    /* Re-scan count badge */
26192    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
26193    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
26194    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
26195    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
26196    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
26197    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
26198    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
26199    body.dark-theme .btn-secondary{color:var(--oxide);}
26200    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
26201    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
26202    /* File input overlay — must be full-width so it aligns with other card-right buttons */
26203    .file-input-wrap{position:relative;width:100%;}
26204    .file-input-wrap .btn{width:100%;}
26205    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
26206    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26207    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26208    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26209    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26210    @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));}}
26211    /* Recent list (card 3 — full-width section below header) */
26212    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
26213    .recent-list{display:flex;flex-direction:column;gap:8px;}
26214    .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;}
26215    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
26216    .recent-item-info{flex:1;min-width:0;}
26217    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
26218    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
26219    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
26220    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
26221    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26222    .site-footer a{color:var(--muted);}
26223    @media(max-width:680px){
26224      .card-body{grid-template-columns:1fr;}
26225      .card-right{flex-direction:row;flex-wrap:wrap;}
26226      .btn{flex:1;}
26227    }
26228    .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;}
26229    .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;}
26230    .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;}
26231  </style>
26232</head>
26233<body>
26234  <div class="background-watermarks" aria-hidden="true">
26235    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26236    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26237    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26238    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26239    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26240    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26241    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26242  </div>
26243  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26244  <div class="top-nav">
26245    <div class="top-nav-inner">
26246      <a class="brand" href="/">
26247        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26248        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
26249      </a>
26250      <div class="nav-right">
26251        <a class="nav-pill" href="/">Home</a>
26252        <div class="nav-dropdown">
26253          <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>
26254          <div class="nav-dropdown-menu">
26255            <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>
26256          </div>
26257        </div>
26258        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26259        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26260        <div class="nav-dropdown">
26261          <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>
26262          <div class="nav-dropdown-menu">
26263            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
26264            <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>
26265          </div>
26266        </div>
26267        <div class="server-status-wrap" id="server-status-wrap">
26268          <div class="nav-pill server-online-pill" id="server-status-pill">
26269            <span class="status-dot" id="status-dot"></span>
26270            <span id="server-status-label">Server</span>
26271            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
26272          </div>
26273          <div class="server-status-tip">
26274            OxideSLOC is running — accessible on your network.
26275            <span class="sx-238af6bc" id="server-tip-ping" ></span>
26276          </div>
26277        </div>
26278        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26279          <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>
26280        </button>
26281        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26282          <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>
26283          <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>
26284        </button>
26285      </div>
26286    </div>
26287  </div>
26288
26289  <div class="page">
26290    <div class="page-header">
26291      <h1>How would you like to scan?</h1>
26292      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
26293    </div>
26294
26295    <div class="option-grid">
26296
26297      <!-- Option 1: New scan -->
26298      <div class="option-card-wrap">
26299        <div class="option-card">
26300        <div class="option-icon new-scan">
26301          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
26302        </div>
26303        <div class="card-body">
26304          <div class="card-left">
26305            <div class="card-text">
26306              <div class="option-title">Start a new scan</div>
26307              <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>
26308              <ul class="feature-list">
26309                <li>Live project scope preview before you run</li>
26310                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
26311                <li>HTML, PDF, and JSON output — your choice</li>
26312              </ul>
26313            </div>
26314          </div>
26315          <div class="card-right">
26316            <a class="btn btn-primary" href="/scan">
26317              Configure &amp; scan
26318              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
26319            </a>
26320            <p class="card-tip">Full 4-step setup · all options</p>
26321          </div>
26322        </div>
26323        </div>
26324      </div>
26325
26326      <!-- Option 2: Load from config file -->
26327      <div class="option-card-wrap">
26328        <div class="option-card">
26329        <div class="option-icon load-config">
26330          <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>
26331        </div>
26332        <div class="card-body">
26333          <div class="card-left">
26334            <div class="card-text">
26335              <div class="option-title">Load a saved config</div>
26336              <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>
26337              <ul class="feature-list">
26338                <li>All 15 settings restored from the file</li>
26339                <li>Fully editable — change path or output dir</li>
26340                <li>Works with any scan-config.json</li>
26341              </ul>
26342            </div>
26343          </div>
26344          <div class="card-right">
26345            <div class="file-input-wrap">
26346              <button class="btn btn-secondary" id="load-config-btn" type="button">
26347                <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>
26348                Choose config file
26349              </button>
26350              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
26351            </div>
26352            <p class="card-tip" id="config-file-name">Exported after every scan</p>
26353          </div>
26354        </div>
26355        </div>
26356      </div>
26357
26358      <!-- Option 3: Re-scan recent project -->
26359      <div class="option-card-wrap">
26360        <div class="option-card" id="recent-card">
26361        <div class="card-top-row">
26362          <div class="option-icon rescan">
26363            <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>
26364          </div>
26365          <div class="card-body">
26366            <div class="card-left">
26367              <div class="card-text">
26368                <div class="option-title">Re-scan a recent project</div>
26369                <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>
26370                <ul class="feature-list">
26371                  <li>All 15+ settings restored from the saved config</li>
26372                  <li>Path and output dir are editable before running</li>
26373                  <li>Only scans with a saved config appear here</li>
26374                </ul>
26375              </div>
26376            </div>
26377            <div class="card-right">
26378              <div class="rescan-count-box">
26379                <div class="rescan-count-num" id="rescan-count-num">—</div>
26380                <div class="rescan-count-label">saved configs</div>
26381              </div>
26382              <a class="btn btn-secondary" href="/view-reports">
26383                <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>
26384                View all runs
26385              </a>
26386              <p class="card-tip">Opens run history</p>
26387            </div>
26388          </div>
26389        </div>
26390        <div class="section-divider"></div>
26391        <div class="recent-list" id="recent-list">
26392          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
26393        </div>
26394        </div>
26395      </div>
26396
26397    </div>
26398  </div>
26399
26400  <footer class="site-footer">
26401    local code analysis - metrics, history and reports
26402    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
26403    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26404    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26405    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26406    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
26407  </footer>
26408
26409  <script nonce="{{ csp_nonce }}">
26410    (function () {
26411      var storageKey = 'oxide-sloc-theme';
26412      var body = document.body;
26413      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26414      var toggle = document.getElementById('theme-toggle');
26415      if (toggle) toggle.addEventListener('click', function () {
26416        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26417        body.classList.toggle('dark-theme', next === 'dark');
26418        try { localStorage.setItem(storageKey, next); } catch(e) {}
26419      });
26420
26421      (function randomizeWatermarks() {
26422        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26423        if (!wms.length) return;
26424        var placed = [];
26425        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; }
26426        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]; }
26427        var half = Math.floor(wms.length / 2);
26428        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; });
26429      })();
26430      (function spawnCodeParticles() {
26431        var container = document.getElementById('code-particles');
26432        if (!container) return;
26433        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
26434        var count = 44;
26435        for (var i = 0; i < count; i++) { (function(idx) { var el = document.createElement('span'); el.className = 'code-particle'; el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1)); 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.108 + 0.072).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); }
26436      })();
26437      // Recent scans data injected from server
26438      var recentScans = {{ recent_scans_json|safe }};
26439
26440      function configToParams(cfg) {
26441        var p = new URLSearchParams();
26442        p.set('prefilled', '1');
26443        if (cfg.path) p.set('path', cfg.path);
26444        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
26445        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
26446        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
26447        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
26448        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
26449        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
26450        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
26451        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
26452        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
26453        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
26454        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
26455        if (cfg.report_title) p.set('report_title', cfg.report_title);
26456        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
26457        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
26458        if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
26459        if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
26460        p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
26461        p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
26462        if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
26463        if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
26464        if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
26465        if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
26466        if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
26467        if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
26468        if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
26469        if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
26470        if (cfg.attribution) p.set('attribution', 'enabled');
26471        return p;
26472      }
26473
26474      // Build recent scan list (capped at 3 visible entries)
26475      var list = document.getElementById('recent-list');
26476      var noNote = document.getElementById('no-recent-note');
26477      var hasAny = false;
26478      var MAX_RECENT = 3;
26479      if (Array.isArray(recentScans)) {
26480        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
26481        var shown = 0;
26482        validEntries.forEach(function (entry) {
26483          if (shown >= MAX_RECENT) return;
26484          shown++;
26485          hasAny = true;
26486          var item = document.createElement('div');
26487          item.className = 'recent-item';
26488          item.title = 'Restore all settings and open wizard';
26489          item.innerHTML =
26490            '<div class="recent-item-info">' +
26491              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
26492              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;\u00b7&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
26493            '</div>' +
26494            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
26495          item.addEventListener('click', function () {
26496            var params = configToParams(entry.config);
26497            window.location.href = '/scan?' + params.toString();
26498          });
26499          list.appendChild(item);
26500        });
26501        if (validEntries.length > MAX_RECENT) {
26502          var moreEl = document.createElement('div');
26503          moreEl.className = 'recent-more-link';
26504          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
26505          list.appendChild(moreEl);
26506        }
26507      }
26508      if (hasAny && noNote) noNote.style.display = 'none';
26509      // Update count badge
26510      var countEl = document.getElementById('rescan-count-num');
26511      if (countEl) {
26512        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
26513        countEl.textContent = total > 0 ? total : '0';
26514      }
26515
26516      // Config file loader
26517      var fileInput = document.getElementById('config-file-input');
26518      var fileName = document.getElementById('config-file-name');
26519      var loadBtn = document.getElementById('load-config-btn');
26520      // Wire the visible button to open the hidden file picker.
26521      if (loadBtn && fileInput) {
26522        loadBtn.addEventListener('click', function () { fileInput.click(); });
26523      }
26524      if (fileInput) {
26525        fileInput.addEventListener('change', function () {
26526          var file = fileInput.files && fileInput.files[0];
26527          if (!file) return;
26528          if (fileName) fileName.textContent = '\u2713 ' + file.name;
26529          var reader = new FileReader();
26530          reader.onload = function (e) {
26531            try {
26532              var cfg = JSON.parse(e.target.result);
26533              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
26534              var params = configToParams(cfg);
26535              window.location.href = '/scan?' + params.toString();
26536            } catch (err) {
26537              alert('Could not parse config file: ' + err.message);
26538            }
26539          };
26540          reader.readAsText(file);
26541        });
26542      }
26543
26544      function escHtml(s) {
26545        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
26546      }
26547    })();
26548  </script>
26549  <script nonce="{{ csp_nonce }}">
26550  (function(){
26551    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'}];
26552    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);});}
26553    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26554    function init(){
26555      var btn=document.getElementById('settings-btn');if(!btn)return;
26556      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26557      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
26558      document.body.appendChild(m);
26559      var g=document.getElementById('scheme-grid');
26560      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);});
26561      var cl=document.getElementById('settings-close');
26562      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);});})();
26563      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');});
26564      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26565      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26566    }
26567    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26568  }());
26569  </script>
26570  <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]';
26571  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;}
26572  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>
26573</body>
26574</html>
26575"##,
26576    ext = "html"
26577)]
26578struct ScanSetupTemplate {
26579    version: &'static str,
26580    recent_scans_json: String,
26581    csp_nonce: String,
26582}
26583
26584#[derive(Template)]
26585#[template(
26586    source = r##"
26587<!doctype html>
26588<html lang="en">
26589<head>
26590  <meta charset="utf-8">
26591  <meta name="viewport" content="width=device-width, initial-scale=1">
26592  <title>OxideSLOC | {{ report_title }} | Report</title>
26593  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26594  <link rel="stylesheet" href="/static/app.css">
26595  <script src="/static/app.js"></script>
26596  <style nonce="{{ csp_nonce }}">
26597    :root {
26598      --radius: 18px;
26599      --bg: #f5efe8;
26600      --surface: rgba(255,255,255,0.82);
26601      --surface-2: #fbf7f2;
26602      --surface-3: #efe6dc;
26603      --line: #e6d0bf;
26604      --line-strong: #dcb89f;
26605      --text: #43342d;
26606      --muted: #7b675b;
26607      --muted-2: #a08777;
26608      --nav: #b85d33;
26609      --nav-2: #7a371b;
26610      --accent: #6f9bff;
26611      --accent-2: #4a78ee;
26612      --oxide: #d37a4c;
26613      --oxide-2: #b35428;
26614      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
26615      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
26616      --success-bg: #e8f5ed;
26617      --success-text: #1a8f47;
26618      --info-bg: #eef3ff;
26619      --info-text: #4467d8;
26620    }
26621
26622    body.dark-theme {
26623      --bg: #1b1511;
26624      --surface: #261c17;
26625      --surface-2: #2d221d;
26626      --surface-3: #372922;
26627      --line: #524238;
26628      --line-strong: #6c5649;
26629      --text: #f5ece6;
26630      --muted: #c7b7aa;
26631      --muted-2: #aa9485;
26632      --nav: #b85d33;
26633      --nav-2: #7a371b;
26634      --accent: #6f9bff;
26635      --accent-2: #4a78ee;
26636      --oxide: #d37a4c;
26637      --oxide-2: #b35428;
26638      --shadow: 0 18px 42px rgba(0,0,0,0.28);
26639      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
26640      --success-bg: #163927;
26641      --success-text: #8fe2a8;
26642      --info-bg: #1c2847;
26643      --info-text: #a9c1ff;
26644    }
26645
26646    * { box-sizing: border-box; }
26647    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); }
26648    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
26649    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
26650    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
26651    .top-nav, .page { position: relative; z-index: 2; }
26652    .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); }
26653    .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; }
26654    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
26655    .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)); }
26656    .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; }
26657    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
26658    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
26659    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
26660    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
26661    .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; }
26662    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
26663    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
26664    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
26665    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26666    @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; } }
26667    .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; }
26668    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
26669    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
26670    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
26671    .theme-toggle .icon-sun { display:none; }
26672    body.dark-theme .theme-toggle .icon-sun { display:block; }
26673    body.dark-theme .theme-toggle .icon-moon { display:none; }
26674    .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;}
26675    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26676    .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);}
26677    .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;}
26678    .settings-close:hover{color:var(--text);background:var(--surface-2);}
26679    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26680    .settings-modal-body{padding:14px 16px 16px;}
26681    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26682    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26683    .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;}
26684    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26685    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26686    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26687    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26688    .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;}
26689    .tz-select:focus{border-color:var(--oxide);}
26690    .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; }
26691    .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;}
26692    .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
26693    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
26694    .hero, .panel { padding: 22px; }
26695    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
26696    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
26697    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
26698    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
26699    .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; }
26700    .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
26701    .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
26702    .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; }
26703    .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
26704    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
26705    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
26706    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
26707    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
26708    .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
26709    .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); }
26710    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
26711    .delta-card-val { font-size:16px; font-weight:800; }
26712    .delta-card-val.pos { color:#1e7e34; }
26713    .delta-card-val.neg { color:var(--neg); }
26714    .delta-card-val.mod { color:#b35428; }
26715    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
26716    .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; }
26717    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
26718    .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
26719    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
26720    .compare-ts { font-size:13px; color:var(--muted); }
26721    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
26722    .compare-arrow { color: var(--muted); }
26723    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
26724    .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; }
26725    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
26726    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
26727    .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
26728    .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; }
26729    .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
26730    .run-mgmt-card .action-buttons { justify-content:center; }
26731    .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
26732    body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
26733    .button, .copy-button {
26734      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;
26735    }
26736    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
26737    @keyframes spin { to { transform: rotate(360deg); } }
26738    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
26739    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
26740    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
26741    .path-item strong { display: block; margin-bottom: 6px; }
26742    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
26743    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
26744    .path-subitem { flex: 1; }
26745    .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); }
26746    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); }
26747    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
26748    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
26749    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
26750    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
26751    th { color: var(--muted); font-weight: 700; }
26752    tr:last-child td { border-bottom: none; }
26753    #subm-tbl col:nth-child(1){width:15%;}
26754    #subm-tbl col:nth-child(2){width:31%;}
26755    #subm-tbl col:nth-child(3){width:9%;}
26756    #subm-tbl col:nth-child(4){width:9%;}
26757    #subm-tbl col:nth-child(5){width:9%;}
26758    #subm-tbl col:nth-child(6){width:9%;}
26759    #subm-tbl col:nth-child(7){width:9%;}
26760    #subm-tbl col:nth-child(8){width:9%;}
26761    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
26762    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
26763    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
26764    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
26765    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
26766    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
26767    .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; }
26768    .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; }
26769    .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
26770    body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
26771    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
26772    .muted { color: var(--muted); }
26773    /* Run-ID chip row (mirrors HTML report) */
26774    .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
26775    @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
26776    @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
26777    .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; }
26778    .run-id-chip[data-copy] { cursor:pointer; }
26779    a.run-id-chip { text-decoration:none; cursor:pointer; }
26780    .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
26781    .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
26782    .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; }
26783    .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
26784    .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
26785    .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
26786    .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
26787    a.commit-link-value { color:inherit; text-decoration:none; }
26788    a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
26789    .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; }
26790    .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
26791    .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
26792    .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
26793    .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; }
26794    body.dark-theme .run-id-short-badge { color:var(--muted-2); }
26795    @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
26796    .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
26797    /* Meta chips row */
26798    .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%; }
26799    .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; }
26800    .meta-chip:last-child { border-right:none; }
26801    .meta-chip b { color:var(--text); font-weight:700; }
26802    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26803    .site-footer a{color:var(--muted);}
26804    .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; }
26805    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
26806    .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; }
26807    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
26808    /* Stat chips (matches HTML report) */
26809    .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
26810    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26811    /* Hero stat strip: uniform grid where every card is the same width and the
26812       columns line up across both rows. JS sets the column count to ceil(n/2) so
26813       the cards always occupy exactly two rows; when the count is odd the last
26814       card spans two columns to fill the trailing cell with no empty gap. */
26815    .summary-strip-hero { align-items:stretch; }
26816    .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; }
26817    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
26818    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
26819    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
26820    .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; }
26821    .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); }
26822    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
26823    .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
26824    .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
26825    /* COCOMO / Tests strips carry only four chips — pin them to four columns so they fill the
26826       box width instead of inheriting the eight-column hero grid and bunching to the left. */
26827    .cocomo-box .summary-strip { grid-template-columns:repeat(4,1fr); margin-top:0; }
26828    @media(max-width:640px){.cocomo-box .summary-strip{grid-template-columns:repeat(2,1fr);}}
26829    .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; }
26830    .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
26831    .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
26832    .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); }
26833    .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); }
26834    .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
26835    .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
26836    .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
26837    /* Code Ownership panel (contributor table + Combine-contributors merge UI) */
26838    .own-result-box .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);}
26839    .own-result-count{font-size:12px;font-weight:700;color:var(--muted);padding:3px 10px;border-radius:999px;background:var(--surface-3);border:1px solid var(--line-strong);}
26840    .own-result-intro{margin-top:2px;margin-bottom:14px;}
26841    .own-result-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:12px;}
26842    .own-result-table{width:100%;border-collapse:collapse;font-size:13px;}
26843    .own-result-table th{background:var(--surface-3);padding:9px 12px;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;}
26844    .own-result-table td{padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:middle;}
26845    .own-result-table tr:last-child td{border-bottom:none;}
26846    .own-result-table tbody tr:hover td{background:var(--surface-2);}
26847    .own-result-table .num{text-align:right;font-variant-numeric:tabular-nums;}
26848    .own-result-table .own-pct{font-weight:700;color:var(--oxide);}
26849    .own-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:middle;}
26850    .own-email{color:var(--muted);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
26851    .own-result-box .author-link{color:var(--oxide-2);text-decoration:none;font-weight:inherit;} .own-result-box .author-link:hover{text-decoration:underline;}
26852    .own-result-box .section-header:first-of-type{border-top:none;padding-top:0;margin-top:20px;}
26853    .own-result-box .panel{margin-bottom:0;}
26854    .merge-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(325px,1fr));gap:11px;margin-bottom:14px;}
26855    .merge-opt{display:flex;align-items:center;gap:11px;padding:13px 17px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);cursor:pointer;transition:transform .22s cubic-bezier(.16,1,.3,1),border-color .18s ease,background .18s ease,box-shadow .22s ease;}
26856    .merge-opt:hover{border-color:var(--oxide);background:var(--surface);transform:translateY(-3px) scale(1.02);box-shadow:0 12px 28px rgba(77,44,20,0.18);}
26857    .merge-opt:hover .merge-opt-dot{transform:scale(1.35);box-shadow:0 0 0 4px rgba(196,92,16,0.15);}
26858    .merge-opt:hover .merge-opt-name{color:var(--oxide-2);}
26859    .merge-opt:active{transform:translateY(-1px) scale(1.0);}
26860    .merge-opt input{accent-color:var(--oxide);width:17px;height:17px;flex:0 0 auto;cursor:pointer;}
26861    .merge-opt-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;transition:transform .2s ease,box-shadow .2s ease;}
26862    .merge-opt-name{font-size:15px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
26863    .merge-opt-email{font-size:12px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;}
26864    .merge-controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;}
26865    .merge-name-input{flex:1 1 240px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);color:var(--text);font-size:13px;outline:none;}
26866    .merge-name-input:focus{border-color:var(--oxide);}
26867    .merge-btn{padding:9px 18px;border:none;border-radius:10px;background:var(--oxide);color:#fff;font-size:13px;font-weight:800;cursor:pointer;transition:background .15s ease,transform .15s ease;}
26868    .merge-btn:hover{background:var(--oxide-2);transform:translateY(-1px);}
26869    .merge-mailmap-link{font-size:12px;font-weight:700;color:var(--oxide-2);text-decoration:none;}
26870    .merge-mailmap-link:hover{text-decoration:underline;}
26871    .merge-existing{margin-top:16px;border-top:1px solid var(--line);padding-top:12px;}
26872    .merge-existing-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px;}
26873    .merge-chip{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);margin-bottom:6px;}
26874    .merge-chip-text{font-size:12px;color:var(--muted);flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26875    .merge-chip-text strong{color:var(--text);font-size:13px;}
26876    .merge-unmerge{padding:5px 12px;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;}
26877    .merge-unmerge:hover{background:var(--surface-2);border-color:var(--oxide);color:var(--oxide-2);}
26878    .merge-mailmap-note{margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-left:3px solid var(--oxide);border-radius:8px;padding:10px 13px;}
26879    .merge-mailmap-note strong{color:var(--text);}
26880    /* Submodule panel */
26881    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
26882    /* Metrics tables stack */
26883    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
26884    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
26885    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
26886    .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)); }
26887    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
26888    /* Metrics table */
26889    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
26890    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
26891    .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; }
26892    .metrics-table thead th:not(:first-child) { text-align: right; }
26893    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
26894    .metrics-table tbody tr:last-child td { border-bottom: none; }
26895    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
26896    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
26897    .metrics-table tbody tr:hover td { background: var(--surface-2); }
26898    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
26899    .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; }
26900    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
26901    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
26902    .mt-val-pos { color: var(--pos); font-weight: 700; }
26903    .mt-val-neg { color: var(--neg); font-weight: 700; }
26904    .mt-val-zero { color: var(--muted); }
26905    .mt-val-mod { color: var(--oxide-2); }
26906    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
26907    @media (max-width: 1180px) {
26908      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
26909      .nav-project-slot, .nav-status { justify-content:flex-start; }
26910      .hero-top { flex-direction: column; }
26911      .run-mgmt-strip { flex-direction: column; }
26912    }
26913    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26914    @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));}}
26915    .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;}
26916    /* ── Result-page chart controls ─────────────────────────────────────────── */
26917    .r-chart-section{margin-bottom:24px;}
26918    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
26919    .section-pair > .panel{flex-shrink:0;}
26920    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
26921    .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;}
26922    .r-chart-select:focus{border-color:var(--accent);}
26923    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
26924    .r-chart-container svg{display:block;width:100%;height:auto;}
26925    .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;}
26926    .r-expand-btn:hover{background:var(--surface);color:var(--text);}
26927    .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;}
26928    .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);}
26929    .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;}
26930    .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
26931    .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
26932    .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
26933    .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;}
26934    .r-chart-modal-close:hover{opacity:.7;}
26935    body.dark-theme .r-chart-modal{background:var(--surface);}
26936    .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;}
26937    .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);}
26938    .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
26939    .lang-bar-row:hover{transform:translateY(-2px);}
26940    .lang-bar-row .rchit:hover{filter:none;transform:none;}
26941    .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
26942    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
26943    .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;}
26944    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
26945    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
26946    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
26947    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
26948    #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;}
26949    .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
26950    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
26951    .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;}
26952    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
26953    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
26954    .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;}
26955    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
26956    .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;}
26957    .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;}
26958    body.has-report-banner .top-nav{top:27px;}
26959    body.has-report-banner{padding-bottom:27px;}
26960  </style>
26961</head>
26962<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
26963  <div class="background-watermarks" aria-hidden="true">
26964    <img src="/images/logo/logo-text.png" alt="" />
26965    <img src="/images/logo/logo-text.png" alt="" />
26966    <img src="/images/logo/logo-text.png" alt="" />
26967    <img src="/images/logo/logo-text.png" alt="" />
26968    <img src="/images/logo/logo-text.png" alt="" />
26969    <img src="/images/logo/logo-text.png" alt="" />
26970    <img src="/images/logo/logo-text.png" alt="" />
26971    <img src="/images/logo/logo-text.png" alt="" />
26972    <img src="/images/logo/logo-text.png" alt="" />
26973    <img src="/images/logo/logo-text.png" alt="" />
26974    <img src="/images/logo/logo-text.png" alt="" />
26975    <img src="/images/logo/logo-text.png" alt="" />
26976    <img src="/images/logo/logo-text.png" alt="" />
26977    <img src="/images/logo/logo-text.png" alt="" />
26978  </div>
26979  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26980  {% if let Some(banner) = report_header_footer %}
26981  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
26982  {% endif %}
26983  <div class="top-nav">
26984    <div class="top-nav-inner">
26985      <a class="brand" href="/">
26986        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26987        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
26988      </a>
26989      <div class="nav-project-slot">
26990        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
26991      </div>
26992      <div class="nav-status">
26993        <a class="nav-pill sx-a85c6157" href="/" >Home</a>
26994        <div class="nav-dropdown">
26995          <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>
26996          <div class="nav-dropdown-menu">
26997            <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>
26998          </div>
26999        </div>
27000        <a class="nav-pill sx-a85c6157" href="/compare-scans" >Compare Scans</a>
27001        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27002        <div class="nav-dropdown">
27003          <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>
27004          <div class="nav-dropdown-menu">
27005            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
27006            <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>
27007          </div>
27008        </div>
27009        <div class="server-status-wrap" id="server-status-wrap">
27010          <div class="nav-pill server-online-pill" id="server-status-pill">
27011            <span class="status-dot" id="status-dot"></span>
27012            <span id="server-status-label">Server</span>
27013            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
27014          </div>
27015          <div class="server-status-tip">
27016            OxideSLOC is running — accessible on your network.
27017            <span class="sx-238af6bc" id="server-tip-ping" ></span>
27018          </div>
27019        </div>
27020        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27021          <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>
27022        </button>
27023        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
27024          <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>
27025          <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>
27026        </button>
27027      </div>
27028    </div>
27029  </div>
27030
27031  <div class="page">
27032    <section class="hero">
27033      <div class="hero-top">
27034        <div>
27035          <div class="sx-5de06b01" >
27036            <h1 class="hero-title sx-38965f9b" >{{ report_title }}</h1>
27037            <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
27038            <div class="soft-chip success sx-c9fd821d" ><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>
27039          </div>
27040        </div>
27041        <div class="hero-quick-actions">
27042          {% if server_mode %}
27043          <button type="button" class="copy-button secondary sx-c7c70665" disabled title="Output folder is on the server — path is not meaningful for remote users" >Copy output folder</button>
27044          {% else %}
27045          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
27046          {% endif %}
27047          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
27048          {% if !server_mode %}
27049          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
27050          {% endif %}
27051          <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
27052          <button class="copy-button sx-7e6e6255" id="delete-run-btn" type="button" >Delete this run</button>
27053        </div>
27054      </div>
27055
27056      <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
27057      <div class="run-id-row">
27058        <span class="run-id-chip" data-copy="{{ run_id }}">
27059          <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>
27060          <span class="run-id-chip-value">{{ run_id }}</span>
27061          <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
27062        </span>
27063        {% match git_commit_long %}
27064          {% when Some with (long_sha) %}
27065          {% match git_commit_url %}
27066            {% when Some with (commit_url) %}
27067            <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
27068              <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 sx-6d255c61" 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" ><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>
27069              <span class="run-id-chip-value">{{ long_sha }}</span>
27070              <span class="chip-tooltip">Open commit on version control — click to navigate</span>
27071            </a>
27072            {% when None %}
27073            <span class="run-id-chip" data-copy="{{ long_sha }}">
27074              <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>
27075              <span class="run-id-chip-value">{{ long_sha }}</span>
27076              <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
27077            </span>
27078          {% endmatch %}
27079          {% when None %}
27080          <span class="run-id-chip muted-chip">
27081            <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>
27082            <span class="run-id-chip-value">Not detected</span>
27083            <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
27084          </span>
27085        {% endmatch %}
27086        {% match git_branch %}
27087          {% when Some with (branch) %}
27088          {% match git_branch_url %}
27089            {% when Some with (branch_url) %}
27090            <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
27091              <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 sx-6d255c61" 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" ><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>
27092              <span class="run-id-chip-value">{{ branch }}</span>
27093              <span class="chip-tooltip">Open branch on version control — click to navigate</span>
27094            </a>
27095            {% when None %}
27096            <span class="run-id-chip">
27097              <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>
27098              <span class="run-id-chip-value">{{ branch }}</span>
27099              <span class="chip-tooltip">Git branch active at scan time</span>
27100            </span>
27101          {% endmatch %}
27102          {% when None %}
27103          <span class="run-id-chip muted-chip">
27104            <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>
27105            <span class="run-id-chip-value">Not detected</span>
27106            <span class="chip-tooltip">No Git branch was found for this scan</span>
27107          </span>
27108        {% endmatch %}
27109        {% match git_author %}
27110          {% when Some with (author) %}
27111          <span class="run-id-chip" data-author="{{ author }}">
27112            <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>
27113            <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
27114            <span class="chip-tooltip">Author of the most recent commit at scan time</span>
27115          </span>
27116          {% when None %}
27117          <span class="run-id-chip muted-chip">
27118            <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>
27119            <span class="run-id-chip-value">Not detected</span>
27120            <span class="chip-tooltip">No commit author was found for this scan</span>
27121          </span>
27122        {% endmatch %}
27123      </div>
27124
27125      <!-- Scan metadata row -->
27126      <div class="meta">
27127        <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
27128        <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
27129        <span class="meta-chip">OS <b>{{ os_display }}</b></span>
27130        <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
27131        <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
27132      </div>
27133
27134      <!-- All summary stat chips in one unified strip (8 columns) -->
27135      <div class="summary-strip summary-strip-hero">
27136        <div class="stat-chip" data-raw="{{ physical_lines }}">
27137          <div class="stat-chip-label">Physical lines</div>
27138          <div class="stat-chip-val">{{ physical_lines }}</div>
27139          <div class="stat-chip-exact"></div>
27140          <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
27141        </div>
27142        <div class="stat-chip" data-raw="{{ code_lines }}">
27143          <div class="stat-chip-label">Code</div>
27144          <div class="stat-chip-val">{{ code_lines }}</div>
27145          <div class="stat-chip-exact"></div>
27146          <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
27147        </div>
27148        <div class="stat-chip" data-raw="{{ comment_lines }}">
27149          <div class="stat-chip-label">Comments</div>
27150          <div class="stat-chip-val">{{ comment_lines }}</div>
27151          <div class="stat-chip-exact"></div>
27152          <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
27153        </div>
27154        <div class="stat-chip" data-raw="{{ blank_lines }}">
27155          <div class="stat-chip-label">Blank</div>
27156          <div class="stat-chip-val">{{ blank_lines }}</div>
27157          <div class="stat-chip-exact"></div>
27158          <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
27159        </div>
27160        <div class="stat-chip" data-raw="{{ mixed_lines }}">
27161          <div class="stat-chip-label">Mixed separate</div>
27162          <div class="stat-chip-val">{{ mixed_lines }}</div>
27163          <div class="stat-chip-exact"></div>
27164          <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
27165        </div>
27166        <div class="stat-chip" data-raw="{{ functions }}">
27167          <div class="stat-chip-label">Functions</div>
27168          <div class="stat-chip-val">{{ functions }}</div>
27169          <div class="stat-chip-exact"></div>
27170          <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
27171        </div>
27172        <div class="stat-chip" data-raw="{{ classes }}">
27173          <div class="stat-chip-label">Classes / Types</div>
27174          <div class="stat-chip-val">{{ classes }}</div>
27175          <div class="stat-chip-exact"></div>
27176          <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
27177        </div>
27178        <div class="stat-chip" data-raw="{{ variables }}">
27179          <div class="stat-chip-label">Variables</div>
27180          <div class="stat-chip-val">{{ variables }}</div>
27181          <div class="stat-chip-exact"></div>
27182          <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
27183        </div>
27184        <div class="stat-chip" data-raw="{{ imports }}">
27185          <div class="stat-chip-label">Imports</div>
27186          <div class="stat-chip-val">{{ imports }}</div>
27187          <div class="stat-chip-exact"></div>
27188          <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
27189        </div>
27190        <div class="stat-chip" data-raw="{{ test_count }}">
27191          <div class="stat-chip-label">Tests</div>
27192          <div class="stat-chip-val">{{ test_count }}</div>
27193          <div class="stat-chip-exact"></div>
27194          <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
27195        </div>
27196        <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
27197          <div class="stat-chip-label">Code density</div>
27198          <div class="stat-chip-val stat-chip-density-val">—</div>
27199          <div class="stat-chip-exact"></div>
27200          <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
27201        </div>
27202        <div class="stat-chip" data-raw="{{ files_analyzed }}">
27203          <div class="stat-chip-label">Files analyzed</div>
27204          <div class="stat-chip-val">{{ files_analyzed }}</div>
27205          <div class="stat-chip-exact"></div>
27206          <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
27207        </div>
27208        {% if cyclomatic_complexity > 0 %}
27209        <div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}data-sx-style="border-color:var(--oxide-2);"{% endif %}>
27210          <div class="stat-chip-label">Complexity score</div>
27211          <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
27212          <div class="stat-chip-exact"></div>
27213          <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>
27214        </div>
27215        {% endif %}
27216        {% if let Some(ls) = lsloc %}
27217        <div class="stat-chip" data-raw="{{ ls }}">
27218          <div class="stat-chip-label">Logical SLOC</div>
27219          <div class="stat-chip-val">{{ ls }}</div>
27220          <div class="stat-chip-exact"></div>
27221          <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>
27222        </div>
27223        {% endif %}
27224        {% if uloc > 0 %}
27225        <div class="stat-chip" data-raw="{{ uloc }}">
27226          <div class="stat-chip-label">Unique SLOC (ULOC)</div>
27227          <div class="stat-chip-val">{{ uloc }}</div>
27228          <div class="stat-chip-exact"></div>
27229          <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>
27230        </div>
27231        {% endif %}
27232        {% if uloc > 0 && dryness_pct_str != "" %}
27233        <div class="stat-chip">
27234          <div class="stat-chip-label">DRYness</div>
27235          <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
27236          <div class="stat-chip-exact"></div>
27237          <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>
27238        </div>
27239        {% endif %}
27240        {% if duplicate_group_count > 0 %}
27241        <div class="stat-chip sx-30e8d2a1" data-raw="{{ duplicate_group_count }}" >
27242          <div class="stat-chip-label">Duplicate groups</div>
27243          <div class="stat-chip-val">{{ duplicate_group_count }}</div>
27244          <div class="stat-chip-exact"></div>
27245          <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>
27246        </div>
27247        {% endif %}
27248        <!-- Reserve "pad" card: revealed by JS only when the visible card count is
27249             odd, so the strip always forms exactly two full rows with every column
27250             aligned and every card the same width (no oversized card, no gap). -->
27251        <div class="stat-chip stat-chip-pad sx-d0466aa3" data-raw="{{ test_assertion_count }}" >
27252          <div class="stat-chip-label">Assertions</div>
27253          <div class="stat-chip-val">{{ test_assertion_count }}</div>
27254          <div class="stat-chip-exact"></div>
27255          <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
27256        </div>
27257      </div>
27258
27259      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
27260      <div class="compare-banner">
27261        <div class="compare-banner-body">
27262          <div class="compare-banner-top">
27263          <div class="compare-banner-meta">
27264            <span class="compare-label">Previous scan</span>
27265            <span class="compare-ts">{{ prev_ts }}</span>
27266            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
27267            {% if let Some(prev_code) = prev_run_code_lines %}
27268            <div class="compare-banner-stats sx-36e81f86" >
27269              <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
27270              <span class="compare-arrow">→</span>
27271              <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
27272              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
27273              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
27274            </div>
27275            {% endif %}
27276          </div>
27277          {% if delta_lines_added.is_some() %}
27278          <div class="delta-cards-inline">
27279            <div class="delta-card-inline">
27280              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
27281              <div class="delta-card-lbl">lines added</div>
27282              <div class="delta-card-tip">Code lines added since the previous scan</div>
27283            </div>
27284            <div class="delta-card-inline">
27285              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v|commas }}{% else %}—{% endif %}</div>
27286              <div class="delta-card-lbl">lines removed</div>
27287              <div class="delta-card-tip">Code lines removed since the previous scan</div>
27288            </div>
27289            <div class="delta-card-inline">
27290              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
27291              <div class="delta-card-lbl">unmodified lines</div>
27292              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
27293            </div>
27294            <div class="delta-card-inline">
27295              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
27296              <div class="delta-card-lbl">files modified</div>
27297              <div class="delta-card-tip">Files with at least one line changed</div>
27298            </div>
27299            <div class="delta-card-inline">
27300              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
27301              <div class="delta-card-lbl">files added</div>
27302              <div class="delta-card-tip">New files added since the previous scan</div>
27303            </div>
27304            <div class="delta-card-inline">
27305              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
27306              <div class="delta-card-lbl">files removed</div>
27307              <div class="delta-card-tip">Files deleted since the previous scan</div>
27308            </div>
27309            <div class="delta-card-inline">
27310              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
27311              <div class="delta-card-lbl">files unchanged</div>
27312              <div class="delta-card-tip">Files with no changes since the previous scan</div>
27313            </div>
27314            <div class="delta-card-inline">
27315              <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
27316              <div class="delta-card-lbl">files total</div>
27317              <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
27318            </div>
27319          </div>
27320          {% else %}
27321          <p class="sx-7c192d5b" >
27322            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
27323          </p>
27324          {% endif %}
27325          </div>
27326          <div class="compare-banner-actions">
27327            <div class="compare-banner-actions-left">
27328              <a class="button secondary sx-32fb29ef" href="/runs/result/{{ prev_id }}" >View previous report</a>
27329              <a class="button secondary sx-32fb29ef" href="/compare-scans" >Compare scans</a>
27330            </div>
27331            <a class="button sx-32fb29ef" href="/compare?a={{ prev_id }}&b={{ run_id }}" >Full diff →</a>
27332          </div>
27333        </div>
27334      </div>
27335      {% endif %}{% endif %}
27336
27337      <div class="action-grid">
27338        <div class="action-card">
27339          <h3>HTML report</h3>
27340          <div class="action-buttons">
27341            {% match html_url %}
27342              {% when Some with (url) %}
27343                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
27344              {% when None %}{% endmatch %}
27345            {% match html_download_url %}
27346              {% when Some with (url) %}
27347                <a class="button secondary" href="{{ url }}">Download HTML</a>
27348              {% when None %}{% endmatch %}
27349            {% match html_path %}
27350              {% when Some with (_path) %}{% when None %}{% endmatch %}
27351            <p class="action-empty-note sx-1d436efc" >Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
27352          </div>
27353        </div>
27354        <div class="action-card">
27355          <h3>PDF report</h3>
27356          <div class="action-buttons">
27357            {% match pdf_url %}
27358              {% when Some with (url) %}
27359                {% if pdf_generating %}
27360                  <button class="button sx-aead66b2" id="pdf-open-btn" disabled >
27361                    <span class="sx-9f3afbfe" ></span>
27362                    Generating PDF…
27363                  </button>
27364                {% else %}
27365                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
27366                {% endif %}
27367              {% when None %}
27368                {% match html_url %}
27369                  {% when Some with (_hurl) %}
27370                    <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
27371                    <p class="action-empty-note sx-df83faee" >Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
27372                  {% when None %}
27373                    <p class="action-empty-note sx-e5c6f3b4" >
27374                      PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
27375                    </p>
27376                {% endmatch %}
27377            {% endmatch %}
27378            {% match pdf_download_url %}
27379              {% when Some with (url) %}
27380                <a class="button secondary sx-884cbe59" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} {% endif %}>Download PDF</a>
27381              {% when None %}{% endmatch %}
27382            {% match pdf_url %}
27383              {% when Some with (_) %}
27384                <p class="action-empty-note sx-1d436efc" >Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
27385              {% when None %}{% endmatch %}
27386          </div>
27387        </div>
27388        <div class="action-card">
27389          <h3>JSON result</h3>
27390          <div class="action-buttons">
27391            {% match json_url %}
27392              {% when Some with (url) %}
27393                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
27394              {% when None %}{% endmatch %}
27395            {% match json_download_url %}
27396              {% when Some with (url) %}
27397                <a class="button secondary" href="{{ url }}">Download JSON</a>
27398              {% when None %}{% endmatch %}
27399            {% match json_path %}
27400              {% when Some with (_path) %}
27401                <p class="action-empty-note sx-1d436efc" >Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
27402              {% when None %}
27403                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
27404              {% endmatch %}
27405          </div>
27406        </div>
27407        <div class="action-card">
27408          <h3>Scan config</h3>
27409          <div class="action-buttons">
27410            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
27411            <a class="button sx-08dc943d" href="/scan-setup" >Run another scan</a>
27412            <p class="action-empty-note sx-1d436efc" >Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
27413          </div>
27414        </div>
27415        {% if confluence_configured %}
27416        <div class="action-card" id="confluenceCard">
27417          <h3>Confluence</h3>
27418          <div class="action-buttons">
27419            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
27420            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
27421          </div>
27422          <p class="action-empty-note sx-1d436efc" >Create or update a Confluence page with this scan result, or copy wiki markup for manual paste.</p>
27423        </div>
27424        {% endif %}
27425      </div>
27426      {% if confluence_configured %}
27427      <div class="sx-75f31a73" id="confluenceModal" >
27428        <div class="sx-3996551e" >
27429          <div class="sx-57b0349b" >Post to Confluence</div>
27430          <label class="sx-806c372e" >Page Title</label>
27431          <input class="sx-1b68562b" id="confPageTitle" type="text" value="OxideSLOC — {{ report_title }}" >
27432          <label class="sx-806c372e" >Report URL <span class="sx-1d258e26" >(optional — linked in page body)</span></label>
27433          <input class="sx-1b68562b" id="confReportUrl" type="url" placeholder="http://127.0.0.1:4317/runs/result/{{ run_id }}" >
27434          <div class="sx-ea0b282d" id="confStatus" ></div>
27435          <div class="sx-49fc5621" >
27436            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
27437            <button class="button" id="confSubmitBtn" type="button">Post</button>
27438          </div>
27439        </div>
27440      </div>
27441      {% endif %}
27442      <div class="sx-aff736e5" id="delete-run-modal" >
27443        <div class="sx-93c3c708" >
27444          <div class="sx-97551196" >Delete run &mdash; irreversible</div>
27445          <p class="sx-aed4a64d" >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>
27446          <div class="sx-1db0ab8d" id="delete-run-status" ></div>
27447          <div class="sx-f154314f" >
27448            <button class="button secondary sx-6f042b89" id="delete-run-cancel" type="button" >Cancel</button>
27449            <button class="button sx-9ea84a6b" id="delete-run-confirm" type="button" >Yes, delete permanently</button>
27450          </div>
27451        </div>
27452      </div>
27453      {% if !submodule_rows.is_empty() %}
27454      <div class="submodule-panel">
27455        <div class="toolbar-row">
27456          <div>
27457            <h2 class="sx-954b0139" >Submodule breakdown</h2>
27458            <p class="muted sx-38965f9b" >Git submodules detected — each is shown as a separate project slice.</p>
27459          </div>
27460          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
27461        </div>
27462        <div class="sx-86e55239" >
27463        <table class="sx-98748fdb" id="subm-tbl" >
27464          <colgroup><col class="sx-295d28ff" ><col class="sx-cffff263" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ></colgroup>
27465          <thead>
27466            <tr>
27467              <th class="sx-d1e8d03c" >Submodule</th>
27468              <th class="sx-e4ba4877" >Path</th>
27469              <th class="sx-d2f0b15c" >Files</th>
27470              <th class="sx-d2f0b15c" >Physical</th>
27471              <th class="sx-d2f0b15c" >Code</th>
27472              <th class="sx-d2f0b15c" >Comments</th>
27473              <th class="sx-d2f0b15c" >Blank</th>
27474              <th class="sx-9eb6a01a" >Report</th>
27475            </tr>
27476          </thead>
27477          <tbody>
27478            {% for row in submodule_rows %}
27479            <tr>
27480              <td class="sx-b03d9852"  title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
27481              <td class="sx-59770ce0"  title="{{ row.relative_path }}"><code class="sx-902e8daa" >{{ row.relative_path }}</code></td>
27482              <td class="sx-a15ec64c" >{{ row.files_analyzed|commas }}</td>
27483              <td class="sx-a15ec64c" >{{ row.total_physical_lines|commas }}</td>
27484              <td class="sx-a15ec64c" >{{ row.code_lines|commas }}</td>
27485              <td class="sx-a15ec64c" >{{ row.comment_lines|commas }}</td>
27486              <td class="sx-a15ec64c" >{{ row.blank_lines|commas }}</td>
27487              <td class="sx-273aef9f" >{% if let Some(url) = row.html_url %}<a class="button sx-66f5341d" href="{{ url }}" target="_blank" rel="noopener" >View</a>{% else %}<span class="sx-3dc23ded" >—</span>{% endif %}</td>
27488            </tr>
27489            {% endfor %}
27490          </tbody>
27491        </table>
27492        </div>
27493      </div>
27494      {% endif %}
27495
27496      <div class="metrics-tables-stack">
27497
27498        <div class="metrics-table-wrap">
27499          <div class="metrics-table-title">Files</div>
27500          <table class="metrics-table">
27501            <thead>
27502              <tr>
27503                <th>Metric</th>
27504                <th>This Run</th>
27505                <th>Previous</th>
27506                <th>Change</th>
27507              </tr>
27508            </thead>
27509            <tbody>
27510              <tr>
27511                <td>Files analyzed</td>
27512                <td class="mt-val-large">{{ files_analyzed|commas }}</td>
27513                <td>{{ prev_fa_str|commas }}</td>
27514                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
27515              </tr>
27516              <tr>
27517                <td>Files skipped</td>
27518                <td>{{ files_skipped|commas }}</td>
27519                <td>{{ prev_fs_str|commas }}</td>
27520                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
27521              </tr>
27522              <tr>
27523                <td>Files modified</td>
27524                <td class="mt-val-na">—</td>
27525                <td class="mt-val-na">—</td>
27526                <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>
27527              </tr>
27528              <tr>
27529                <td>Files unchanged</td>
27530                <td class="mt-val-na">—</td>
27531                <td class="mt-val-na">—</td>
27532                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
27533              </tr>
27534              <tr>
27535                <td>Files total</td>
27536                <td class="mt-val-na">—</td>
27537                <td class="mt-val-na">—</td>
27538                <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
27539              </tr>
27540            </tbody>
27541          </table>
27542        </div>
27543
27544        <div class="metrics-table-wrap">
27545          <div class="metrics-table-title">Line Counts</div>
27546          <table class="metrics-table">
27547            <thead>
27548              <tr>
27549                <th>Metric</th>
27550                <th>This Run</th>
27551                <th>Previous</th>
27552                <th>Change</th>
27553              </tr>
27554            </thead>
27555            <tbody>
27556              <tr>
27557                <td>Physical lines</td>
27558                <td class="mt-val-large">{{ physical_lines|commas }}</td>
27559                <td>{{ prev_pl_str|commas }}</td>
27560                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
27561              </tr>
27562              <tr>
27563                <td>Code lines</td>
27564                <td class="mt-val-large">{{ code_lines|commas }}</td>
27565                <td>{{ prev_cl_str|commas }}</td>
27566                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
27567              </tr>
27568              <tr>
27569                <td>Comment lines</td>
27570                <td>{{ comment_lines|commas }}</td>
27571                <td>{{ prev_cml_str|commas }}</td>
27572                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
27573              </tr>
27574              <tr>
27575                <td>Blank lines</td>
27576                <td>{{ blank_lines|commas }}</td>
27577                <td>{{ prev_bl_str|commas }}</td>
27578                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
27579              </tr>
27580              <tr>
27581                <td>Mixed (separate)</td>
27582                <td>{{ mixed_lines|commas }}</td>
27583                <td class="mt-val-na">—</td>
27584                <td class="mt-val-na">—</td>
27585              </tr>
27586            </tbody>
27587          </table>
27588        </div>
27589
27590        <div class="metrics-tables-lower">
27591          <div class="metrics-table-wrap">
27592            <div class="metrics-table-title">Code Structure</div>
27593            <table class="metrics-table">
27594              <thead>
27595                <tr>
27596                  <th>Metric</th>
27597                  <th>This Run</th>
27598                </tr>
27599              </thead>
27600              <tbody>
27601                <tr>
27602                  <td>Functions</td>
27603                  <td>{{ functions|commas }}</td>
27604                </tr>
27605                <tr>
27606                  <td>Classes / Types</td>
27607                  <td>{{ classes|commas }}</td>
27608                </tr>
27609                <tr>
27610                  <td>Variables</td>
27611                  <td>{{ variables|commas }}</td>
27612                </tr>
27613                <tr>
27614                  <td>Imports</td>
27615                  <td>{{ imports|commas }}</td>
27616                </tr>
27617              </tbody>
27618            </table>
27619          </div>
27620
27621          <div class="metrics-table-wrap">
27622            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
27623            <table class="metrics-table">
27624              <thead>
27625                <tr>
27626                  <th>Metric</th>
27627                  <th>Change</th>
27628                </tr>
27629              </thead>
27630              <tbody>
27631                <tr>
27632                  <td>Lines added</td>
27633                  <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>
27634                </tr>
27635                <tr>
27636                  <td>Lines removed</td>
27637                  <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>
27638                </tr>
27639                <tr>
27640                  <td>Lines modified (net)</td>
27641                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
27642                </tr>
27643                <tr>
27644                  <td>Lines unmodified</td>
27645                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
27646                </tr>
27647              </tbody>
27648            </table>
27649          </div>
27650        </div>
27651
27652      </div>
27653
27654      <div class="path-list">
27655        <div class="path-item">
27656          <div class="path-item-label">Project path</div>
27657          {% if project_path.is_empty() %}<code class="sx-eac76940"  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 %}
27658        </div>
27659        <div class="path-item">
27660          <div class="path-item-label">Git branch</div>
27661          {% if let Some(branch) = git_branch %}
27662          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
27663          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
27664          {% else %}
27665          <code class="sx-eac76940" >—</code>
27666          {% endif %}
27667        </div>
27668        <div class="path-item">
27669          <div class="path-item-label">Output folder</div>
27670          <code class="sx-e765c2e1" >{{ output_dir }}</code>
27671        </div>
27672        <div class="path-item">
27673          <div class="path-item-label">Run ID</div>
27674          <div class="sx-ed46f3ae" >
27675            <code class="sx-1d11d813" >{{ run_id }}</code>
27676            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
27677          </div>
27678        </div>
27679      </div>
27680    </section>
27681
27682    {% if has_cocomo %}
27683    <div class="cocomo-box sx-04bbec5e" >
27684      <div class="cocomo-box-head">
27685        <span class="cocomo-box-title">Constructive Cost Model &mdash; COCOMO I</span>
27686        <span class="cocomo-mode-pill-wrap sx-8a450d4e" >
27687          <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
27688          <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
27689        </span>
27690      </div>
27691      <div class="summary-strip sx-8fd19811" >
27692        <div class="stat-chip">
27693          <div class="stat-chip-label">Person-months</div>
27694          <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
27695          <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>
27696        </div>
27697        <div class="stat-chip">
27698          <div class="stat-chip-label">Schedule (months)</div>
27699          <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
27700          <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>
27701        </div>
27702        <div class="stat-chip">
27703          <div class="stat-chip-label">Avg. Team Size</div>
27704          <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
27705          <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>
27706        </div>
27707        <div class="stat-chip">
27708          <div class="stat-chip-label">Input KSLOC</div>
27709          <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
27710          <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>
27711        </div>
27712      </div>
27713      <div class="cocomo-box-note sx-32fb29ef" >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>
27714    </div>
27715    {% endif %}
27716
27717    <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
27718    <div class="cocomo-box sx-04bbec5e" >
27719      <div class="cocomo-box-head">
27720        <span class="cocomo-box-title">Tests &amp; Coverage</span>
27721        {% if has_coverage_data %}
27722        <span class="cocomo-mode-pill-wrap sx-8a450d4e" >
27723          <span class="cocomo-mode-pill sx-c586fb0a" >Coverage data present</span>
27724        </span>
27725        {% endif %}
27726      </div>
27727      <div class="summary-strip sx-8fd19811" >
27728        <div class="stat-chip">
27729          <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
27730          <div class="stat-chip-label">Test Functions</div>
27731          <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
27732        </div>
27733        <div class="stat-chip">
27734          {% if has_coverage_data %}
27735          <div class="stat-chip-val sx-ccd997a1" >{{ cov_line_pct }}%</div>
27736          {% else %}
27737          <div class="stat-chip-val sx-911ada61" >&mdash;</div>
27738          {% endif %}
27739          <div class="stat-chip-label">Line Coverage</div>
27740          <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
27741        </div>
27742        <div class="stat-chip">
27743          {% if !cov_fn_pct.is_empty() %}
27744          <div class="stat-chip-val sx-ccd997a1" >{{ cov_fn_pct }}%</div>
27745          {% else %}
27746          <div class="stat-chip-val sx-911ada61" >&mdash;</div>
27747          {% endif %}
27748          <div class="stat-chip-label">Fn Coverage</div>
27749          <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
27750        </div>
27751        <div class="stat-chip">
27752          {% if !cov_branch_pct.is_empty() %}
27753          <div class="stat-chip-val sx-ccd997a1" >{{ cov_branch_pct }}%</div>
27754          {% else %}
27755          <div class="stat-chip-val sx-911ada61" >&mdash;</div>
27756          {% endif %}
27757          <div class="stat-chip-label">Branch Coverage</div>
27758          <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
27759        </div>
27760      </div>
27761      {% if has_coverage_data %}
27762      <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>
27763      {% else %}
27764      <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>
27765      {% endif %}
27766    </div>
27767
27768    {{ ownership_html|safe }}
27769
27770    <div class="section-pair">
27771    <section class="panel">
27772        <div class="toolbar-row">
27773          <div>
27774            <h2>Language Breakdown</h2>
27775            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
27776          </div>
27777          <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27778        </div>
27779        <div class="sx-bb5bea38" id="result-lang-charts" ></div>
27780    </section>
27781
27782    <section class="panel r-chart-section">
27783      <div class="toolbar-row sx-7564ec60" >
27784        <div>
27785          <h2>Visualizations</h2>
27786          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
27787        </div>
27788      </div>
27789
27790      <div class="r-viz-grid">
27791        <div class="r-viz-card">
27792          <div class="sx-3a744011" >
27793            <p class="r-viz-card-title sx-1181d69d" >Language Composition</p>
27794            <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27795          </div>
27796          <div class="r-chart-tab-bar">
27797            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
27798            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
27799          </div>
27800          <div class="r-chart-container" id="r-composition-chart"></div>
27801        </div>
27802        <div class="r-viz-card">
27803          <div class="sx-66b97f4b" >
27804            <p class="r-viz-card-title sx-1181d69d" >Files vs Code Lines</p>
27805            <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27806          </div>
27807          <div class="r-chart-container" id="r-scatter-chart"></div>
27808        </div>
27809        {% if has_semantic_data %}
27810        <div class="r-viz-card">
27811          <div class="sx-3a744011" >
27812            <p class="r-viz-card-title sx-1181d69d" >Semantic Metrics</p>
27813            <select class="r-chart-select" id="r-semantic-metric">
27814              <option value="functions">Functions</option>
27815              <option value="classes">Classes</option>
27816              <option value="variables">Variables</option>
27817              <option value="imports">Imports</option>
27818              <option value="tests">Tests</option>
27819            </select>
27820            <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27821          </div>
27822          <div class="r-chart-container" id="r-semantic-chart"></div>
27823        </div>
27824        {% endif %}
27825        <div class="r-viz-card">
27826          <div class="sx-66b97f4b" >
27827            <p class="r-viz-card-title sx-1181d69d" >Comment Density</p>
27828            <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27829          </div>
27830          <div class="r-chart-container" id="r-density-chart"></div>
27831        </div>
27832        <div class="r-viz-card">
27833          <div class="sx-66b97f4b" >
27834            <p class="r-viz-card-title sx-1181d69d" >Avg Lines per File</p>
27835            <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27836          </div>
27837          <div class="r-chart-container" id="r-avglines-chart"></div>
27838        </div>
27839        <div class="r-viz-card">
27840          <div class="sx-9ef668bf" >
27841            <p class="r-viz-card-title sx-1181d69d" >Repository Overview</p>
27842            <select class="r-chart-select" id="r-sub-metric">
27843              <option value="code">Code Lines</option>
27844              <option value="comment">Comments</option>
27845              <option value="blank">Blank Lines</option>
27846              <option value="physical">Physical Lines</option>
27847              <option value="files">Files</option>
27848            </select>
27849            <select class="r-chart-select" id="r-sub-sort">
27850              <option value="desc">Value ↓</option>
27851              <option value="asc">Value ↑</option>
27852              <option value="name">Name A→Z</option>
27853            </select>
27854            <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">&#x2922; Full View</button>
27855          </div>
27856          <div class="r-chart-container" id="r-submodule-chart"></div>
27857        </div>
27858      </div>
27859
27860    </section>
27861    </div>
27862
27863  </div>
27864
27865  <div id="r-tt" aria-hidden="true"></div>
27866
27867  <script nonce="{{ csp_nonce }}">
27868    (function () {
27869      var body = document.body;
27870      var themeToggle = document.getElementById('theme-toggle');
27871      var storageKey = 'oxide-sloc-theme';
27872
27873      function applyTheme(theme) {
27874        body.classList.toggle('dark-theme', theme === 'dark');
27875      }
27876
27877      function loadSavedTheme() {
27878        try {
27879          var saved = localStorage.getItem(storageKey);
27880          if (saved === 'dark' || saved === 'light') {
27881            applyTheme(saved);
27882          }
27883        } catch (e) {}
27884      }
27885
27886      if (themeToggle) {
27887        themeToggle.addEventListener('click', function () {
27888          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
27889          applyTheme(nextTheme);
27890          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
27891        });
27892      }
27893
27894      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
27895        button.addEventListener('click', function () {
27896          var value = button.getAttribute('data-copy-value') || '';
27897          if (!value) return;
27898          var originalText = button.textContent;
27899          function flashSuccess() {
27900            button.textContent = 'Copied!';
27901            setTimeout(function () { button.textContent = originalText; }, 1800);
27902          }
27903          function flashFail() {
27904            button.textContent = 'Copy failed';
27905            setTimeout(function () { button.textContent = originalText; }, 2000);
27906          }
27907          if (navigator.clipboard && navigator.clipboard.writeText) {
27908            navigator.clipboard.writeText(value).then(flashSuccess, function () {
27909              fallbackCopy(value, flashSuccess, flashFail);
27910            });
27911          } else {
27912            fallbackCopy(value, flashSuccess, flashFail);
27913          }
27914        });
27915      });
27916      function fallbackCopy(text, onSuccess, onFail) {
27917        try {
27918          var ta = document.createElement('textarea');
27919          ta.value = text;
27920          ta.style.position = 'fixed';
27921          ta.style.top = '-9999px';
27922          ta.style.left = '-9999px';
27923          document.body.appendChild(ta);
27924          ta.focus();
27925          ta.select();
27926          var ok = document.execCommand('copy');
27927          document.body.removeChild(ta);
27928          if (ok) { onSuccess(); } else { onFail(); }
27929        } catch (e) { onFail(); }
27930      }
27931
27932      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
27933        btn.addEventListener('click', function () {
27934          var folder = btn.getAttribute('data-folder') || '';
27935          if (!folder) return;
27936          var orig = btn.textContent;
27937          fetch('/open-path?path=' + encodeURIComponent(folder))
27938            .then(function (r) { return r.json(); })
27939            .then(function (d) {
27940              if (d && d.server_mode_disabled) {
27941                window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
27942              } else if (d && d.ok) {
27943                btn.textContent = 'Opened!';
27944                setTimeout(function () { btn.textContent = orig; }, 1800);
27945              }
27946            })
27947            .catch(function () {
27948              btn.textContent = 'Failed';
27949              setTimeout(function () { btn.textContent = orig; }, 2000);
27950            });
27951        });
27952      });
27953
27954      loadSavedTheme();
27955
27956      // ── Compact number formatting for stat chips ──────────────────────────
27957      (function(){
27958        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();}
27959        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
27960          var raw=parseInt(chip.getAttribute('data-raw'),10);
27961          if(isNaN(raw))return;
27962          var valEl=chip.querySelector('.stat-chip-val');
27963          if(valEl)valEl.textContent=fmt(raw);
27964          var exactEl=chip.querySelector('.stat-chip-exact');
27965          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
27966        });
27967        // Code density chip
27968        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
27969          var code=parseInt(chip.getAttribute('data-code'),10);
27970          var phys=parseInt(chip.getAttribute('data-physical'),10);
27971          if(isNaN(code)||isNaN(phys)||phys===0)return;
27972          var pct=(code/phys*100).toFixed(1)+'%';
27973          var valEl=chip.querySelector('.stat-chip-val');
27974          if(valEl)valEl.textContent=pct;
27975        });
27976        // Populate author handle from data-author attribute
27977        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
27978          var author=chip.getAttribute('data-author');
27979          var el=chip.querySelector('.author-handle');
27980          if(el)el.textContent='/'+author.replace(/\s+/g,'');
27981        });
27982        // Click-to-copy on run-id-chip elements
27983        Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
27984          chip.addEventListener('click',function(){
27985            var val=chip.getAttribute('data-copy');
27986            if(!val)return;
27987            if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
27988            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);}
27989            chip.classList.add('chip-copied-flash');
27990            setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
27991          });
27992        });
27993        // Format delta card values with data-raw using comma-separated full numbers
27994        Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
27995          var raw=parseInt(card.getAttribute('data-raw'),10);
27996          if(isNaN(raw))return;
27997          var valEl=card.querySelector('.delta-card-val');
27998          if(valEl)valEl.textContent=raw.toLocaleString();
27999        });
28000        // Format code-before / code-now numbers in the compare banner stats line
28001        Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
28002          var raw=parseInt(el.getAttribute('data-raw'),10);
28003          if(!isNaN(raw))el.textContent=raw.toLocaleString();
28004        });
28005      })();
28006
28007      // ── Shared tooltip for all result-page charts ─────────────────────────
28008      var rTT=(function(){
28009        var el=document.getElementById('r-tt');
28010        if(!el)return{s:function(){},h:function(){},m:function(){}};
28011        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
28012        function hide(){el.style.display='none';}
28013        function move(e){
28014          var x=e.clientX+16,y=e.clientY-12;
28015          var r=el.getBoundingClientRect();
28016          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
28017          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
28018          el.style.left=x+'px';el.style.top=y+'px';
28019        }
28020        return{s:show,h:hide,m:move};
28021      })();
28022      window.rTT=rTT;
28023
28024      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
28025      (function(){
28026        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
28027        document.addEventListener('mouseover',function(e){
28028          var t=e.target;
28029          while(t&&t.getAttribute){
28030            var l=t.getAttribute('data-ttl');
28031            if(l!==null){
28032              var v=t.getAttribute('data-ttv')||'';
28033              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
28034              return;
28035            }
28036            t=t.parentNode;
28037          }
28038        });
28039        document.addEventListener('mouseout',function(e){
28040          var t=e.target;
28041          while(t&&t.getAttribute){
28042            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
28043            t=t.parentNode;
28044          }
28045        });
28046        document.addEventListener('mousemove',function(e){
28047          var el=document.getElementById('r-tt');
28048          if(el&&el.style.display!=='none')rTT.m(e);
28049        });
28050        window.addEventListener('blur',function(){rTT.h();});
28051        document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
28052      })();
28053
28054      // ── Language overview charts ───────────────────────────────────────────
28055      (function(){
28056        var D={{ lang_chart_json|safe }};
28057        if(!D||!D.length)return;
28058        var el=document.getElementById('result-lang-charts');
28059        if(!el)return;
28060        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
28061        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
28062        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
28063        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();}
28064        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
28065        function px(n){return Math.round(n);}
28066        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+'"';}
28067        // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
28068        // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
28069        // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
28070        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;}
28071        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
28072
28073        // Donut chart — height matches the stacked-bar chart so both panels align
28074        var rHb_d=28;
28075        var DH=Math.max(220,D.length*rHb_d+32);
28076        var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
28077        var legX=208,DW=395;
28078        var legCount=D.length;
28079        var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
28080        var legYStart=Math.round((DH-legCount*legSpacing)/2);
28081        var ds='<svg class="sx-b882cc83" id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'"  xmlns="http://www.w3.org/2000/svg">';
28082        // One shared transition on every donut element so slices, leader lines,
28083        // outside labels, % labels and the legend all animate together as a single
28084        // picture when a language is hovered. Slices scale from the donut centre.
28085        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>';
28086        if(D.length===1){
28087          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
28088          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+'"/>';
28089        } else {
28090          var smalls=[];
28091          var ang=-Math.PI/2;
28092          D.forEach(function(d,i){
28093            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
28094            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
28095            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
28096            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
28097            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
28098            var pct=Math.round(d.code/tot*100);
28099            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"/>';
28100            if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text class="sx-c3270469" 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" >'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
28101            ang+=sw;
28102          });
28103          // Small slices (<5%) get outside labels positioned near each slice's own
28104          // angular position (a slice on the left gets its label/leader on the left),
28105          // then nudged apart horizontally so text never overlaps. Leader lines point
28106          // from each slice to its label. Horizontal text keeps long names legible;
28107          // the whole SVG scales up in Full View so these stay readable there too.
28108          if(smalls.length){
28109            smalls.sort(function(a,b){return a.mAng-b.mAng;});
28110            var sPad=6,sRowY=11;
28111            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)));});
28112            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;}
28113            var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
28114            if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
28115            smalls.forEach(function(sm){
28116              var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
28117              ds+='<line class="sx-c3270469" 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" />';
28118              ds+='<text class="sx-83ac1cee" 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+'" >'+esc(sm.txt)+'</text>';
28119            });
28120          }
28121        }
28122        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
28123        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
28124        D.forEach(function(d,i){
28125          var ly=legYStart+i*legSpacing;
28126          var pctL=Math.round(d.code/tot*100);
28127          var ttL=String(d.lang).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
28128          var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&amp;').replace(/"/g,'&quot;');
28129          ds+='<g class="sx-83ac1cee" data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" >';
28130          ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
28131          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
28132          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
28133          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>';
28134          ds+='</g>';
28135        });
28136        ds+='</svg>';
28137
28138        // Horizontal stacked-bar chart — fills container width
28139        var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
28140        var LW=108,BW=260,svgW=LW+BW+68;
28141        var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
28142        var barBH=Math.min(32,Math.round(barRhb*0.7));
28143        var SH=DH;
28144        var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
28145        var bs='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'"  xmlns="http://www.w3.org/2000/svg">';
28146        D.forEach(function(d,i){
28147          var y=barTopPad+i*barRhb,x=LW;
28148          var phys=d.physical||d.code+d.comments+d.blanks;
28149          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
28150          var lmid=y+barBH/2+4;
28151          // Combined breakdown shown when hovering the row, the language name, or the
28152          // total at the bar end (\n becomes a line break in the tooltip).
28153          var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
28154          bs+='<g class="lang-bar-row">';
28155          // Hit area ends just past the total label so empty space to the right of the
28156          // bar does not trigger the tooltip — only the name, bar and total are hot.
28157          var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
28158          bs+='<rect class="sx-83ac1cee"'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" />';
28159          bs+='<text class="sx-83ac1cee"'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" >'+esc(d.lang)+'</text>';
28160          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 class="sx-c3270469" x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" >'+fmt(d.code)+'</text>';x+=cW;}
28161          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 class="sx-c3270469" x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" >'+fmt(d.comments)+'</text>';x+=cmW;}
28162          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 class="sx-c3270469" x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" >'+fmt(d.blanks)+'</text>';}
28163          bs+='<text class="sx-83ac1cee"'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" >'+fmt(phys)+'</text>';
28164          bs+='</g>';
28165        });
28166        var ly=SH-14;
28167        var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
28168        var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
28169        var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
28170        var totAll=totC+totCm+totBl||1;
28171        function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
28172        var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
28173        var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
28174        var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
28175        var legSt=LW+Math.max(0,Math.round((BW-194)/2));
28176        bs+='<g class="sx-83ac1cee" data-kind="code" >'
28177          +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
28178          +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
28179          +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
28180          +'</g>';
28181        bs+='<g class="sx-83ac1cee" data-kind="comment" >'
28182          +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
28183          +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
28184          +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
28185          +'</g>';
28186        bs+='<g class="sx-83ac1cee" data-kind="blank" >'
28187          +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
28188          +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
28189          +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
28190          +'</g>';
28191        bs+='</svg>';
28192        el.innerHTML='<div class="r-lang-overview">'+
28193          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
28194          '<div class="r-lang-overview-cell sx-096e0509" ><p>Line Mix per Language</p>'+bs+'</div>'+
28195        '</div>';
28196        function wireDonutLegend(svg){
28197          if(!svg)return;
28198          // Every donut element carries data-lang: slices (path/circle), leader lines,
28199          // outside labels + % labels (text) and legend rows (g). Hovering any one of
28200          // them emphasises that language across all of them and fades the rest, so the
28201          // slice, its leader line, its label and its legend row move as one picture.
28202          var items=svg.querySelectorAll('[data-lang]');
28203          function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
28204            var tag=el.tagName.toLowerCase();
28205            if(tag==='path'||tag==='circle'){
28206              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)';}
28207              else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
28208              else{el.style.opacity='';el.style.filter='';el.style.transform='';}
28209            }else if(tag==='line'){
28210              if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
28211              else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
28212              else{el.style.opacity='';el.style.strokeWidth='';}
28213            }else if(tag==='text'){
28214              if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
28215              else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
28216              else{el.style.opacity='';el.style.fontWeight='';}
28217            }else{ // legend group
28218              if(st===1){el.style.opacity='1';}
28219              else if(st===-1){el.style.opacity='0.4';}
28220              else{el.style.opacity='';}
28221            }
28222          }
28223          function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
28224          function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
28225          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();});
28226          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();});
28227          svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
28228        }
28229        function wireMixLegend(svg){
28230          if(!svg)return;
28231          var legGs=svg.querySelectorAll('g[data-kind]');
28232          var allRects=svg.querySelectorAll('rect[data-kind]');
28233          if(!legGs.length)return;
28234          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';}}
28235          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='';}}
28236          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]);}
28237        }
28238        wireDonutLegend(el.querySelector('svg'));
28239        wireMixLegend(el.querySelectorAll('svg')[1]);
28240
28241        // ── Language breakdown Full View expand ─────────────────────────────────
28242        var langOvBtn=document.getElementById('result-lang-overview-expand');
28243        if(langOvBtn){langOvBtn.addEventListener('click',function(){
28244          var src=document.getElementById('result-lang-charts');
28245          if(!src)return;
28246          var overlay=document.createElement('div');
28247          overlay.className='r-chart-modal-overlay';
28248          overlay.innerHTML='<div class="r-chart-modal sx-eb9a8e19" ><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 class="sx-9ccc4ca9" id="result-lang-overview-modal-wrap" ></div></div>';
28249          document.body.appendChild(overlay);
28250          overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
28251          overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
28252          var wrap=document.getElementById('result-lang-overview-modal-wrap');
28253          if(wrap){
28254            wrap.innerHTML=src.innerHTML;
28255            var svgs=wrap.querySelectorAll('svg');
28256            for(var i=0;i<svgs.length;i++){
28257              svgs[i].removeAttribute('width');
28258              svgs[i].removeAttribute('height');
28259              svgs[i].style.cssText='display:block;width:100%;height:auto;';
28260            }
28261            var ov=wrap.querySelector('.r-lang-overview');
28262            if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
28263            var cells=wrap.querySelectorAll('.r-lang-overview-cell');
28264            if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
28265            if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
28266            wireDonutLegend(wrap.querySelector('svg'));
28267            wireMixLegend(wrap.querySelectorAll('svg')[1]);
28268            requestAnimationFrame(function(){
28269              var ss=wrap.querySelectorAll('svg');
28270              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%;';}}
28271            });
28272          }
28273        });}
28274      })();
28275
28276      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
28277      (function(){
28278        var LANG_D={{ lang_chart_json|safe }};
28279        var SCAT_D={{ scatter_chart_json|safe }};
28280        var SEM_D={{ semantic_chart_json|safe }};
28281        var SUB_D={{ submodule_chart_json|safe }};
28282        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
28283        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
28284        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();}
28285        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
28286        function px(n){return Math.round(n);}
28287        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+'"';}
28288        // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
28289        // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
28290        // rather than disappear; the SVG scales up in Full View).
28291        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;}
28292
28293        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
28294        function renderCompositionInEl(el,mode,shOvr){
28295          if(!el||!LANG_D||!LANG_D.length)return;
28296          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
28297          var LW=110,SH=shOvr||300;
28298          var svgW=Math.max(320,el.offsetWidth||480);
28299          var BW=Math.max(120,svgW-LW-80);
28300          var legendH=24,topPad=4;
28301          var n=LANG_D.length||1;
28302          var rowTotal=Math.floor((SH-legendH-topPad)/n);
28303          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
28304          var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'"  xmlns="http://www.w3.org/2000/svg">';
28305          var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
28306          var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
28307          var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
28308          var totAll2=totC2+totCm2+totBl2||1;
28309          if(mode==='pct'){
28310            LANG_D.forEach(function(d,i){
28311              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
28312              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
28313              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
28314              var lmid=y+Math.floor(bH/2)+4;
28315              var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
28316              s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" >'+esc(d.lang)+'</text>';
28317              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 class="sx-c3270469" x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" >'+fmt(d.code||0)+'</text>';x+=cW;}
28318              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 class="sx-c3270469" x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" >'+fmt(d.comments||0)+'</text>';x+=cmW;}
28319              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 class="sx-c3270469" x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" >'+fmt(d.blanks||0)+'</text>';}
28320              var pct=Math.round((d.code||0)/tot2*100);
28321              s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" >'+pct+'%</text>';
28322            });
28323          } else {
28324            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
28325            LANG_D.forEach(function(d,i){
28326              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
28327              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
28328              var lmid=y+Math.floor(bH/2)+4;
28329              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));
28330              s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" >'+esc(d.lang)+'</text>';
28331              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 class="sx-c3270469" x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" >'+fmt(d.code||0)+'</text>';x+=cW;}
28332              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 class="sx-c3270469" x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" >'+fmt(d.comments||0)+'</text>';x+=cmW;}
28333              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 class="sx-c3270469" x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" >'+fmt(d.blanks||0)+'</text>';}
28334              s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" >'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
28335            });
28336          }
28337          var ly=SH-legendH+4;
28338          var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
28339          function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'&quot;')+'"';}
28340          var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
28341          var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
28342          var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
28343          s+='<g class="sx-83ac1cee" data-kind="code" >'
28344            +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
28345            +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
28346            +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
28347            +'</g>';
28348          s+='<g class="sx-83ac1cee" data-kind="comment" >'
28349            +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
28350            +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
28351            +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
28352            +'</g>';
28353          s+='<g class="sx-83ac1cee" data-kind="blank" >'
28354            +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
28355            +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
28356            +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
28357            +'</g>';
28358          s+='</svg>';
28359          el.innerHTML=s;
28360          wireMixLegendEl(el);
28361        }
28362        function wireMixLegendEl(container){
28363          var svg=container&&container.querySelector('svg');
28364          if(!svg)return;
28365          var legGs=svg.querySelectorAll('g[data-kind]');
28366          var allRects=svg.querySelectorAll('rect[data-kind]');
28367          if(!legGs.length)return;
28368          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';}}
28369          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='';}}
28370          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]);}
28371        }
28372        function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
28373        renderComposition('abs');
28374        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
28375          btn.addEventListener('click',function(){
28376            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
28377            btn.classList.add('active');
28378            renderComposition(btn.getAttribute('data-rcomp'));
28379          });
28380        });
28381
28382        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
28383        function wireScatterLegend(container){
28384          var svg=container&&container.querySelector('svg');
28385          if(!svg)return;
28386          var legGs=svg.querySelectorAll('g[data-lang]');
28387          var circs=svg.querySelectorAll('circle[data-lang]');
28388          var labs=svg.querySelectorAll('text[data-lang]');
28389          if(!legGs.length)return;
28390          // Raise an element to the top of its parent so the hovered bubble and its
28391          // name/number labels sit above overlapping neighbours (clustered bubbles
28392          // otherwise bury the one you are trying to read).
28393          function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
28394          function hl(lang){
28395            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';}}
28396            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';}}
28397            for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
28398          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='';}}
28399          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]);}
28400        }
28401        function renderScatterInEl(el,hOvr){
28402          if(!el||!SCAT_D||!SCAT_D.length)return;
28403          var n=SCAT_D.length;
28404          var H=hOvr||300,PL=52,PB=36,PT=44;
28405          var W=Math.max(320,el.offsetWidth||480);
28406          var cH=H-PT-PB;
28407          // Legend: max 2 columns, fills vertical space. The compact card shows the
28408          // top languages by code lines plus a "+N more" row linking to Full View;
28409          // Full View (hOvr set) shows every language across up to 2 tall columns.
28410          var compact=!hOvr;
28411          var availH=Math.max(120,H-24);
28412          var rowsFit=Math.max(2,Math.floor(availH/18));
28413          var legTrunc=compact&&(n>2*rowsFit);
28414          var legShown=legTrunc?(2*rowsFit-1):n;
28415          var legTotal=legTrunc?(2*rowsFit):n;
28416          var legCols=legTotal>Math.min(rowsFit,18)?2:1;
28417          var legPerCol=Math.ceil(legTotal/legCols);
28418          var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
28419          var legColW=hOvr?144:130;
28420          var LG=26;
28421          var legW=legCols*legColW;
28422          var cW=W-PL-LG-legW;
28423          var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
28424          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
28425          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
28426          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
28427          // log1p scale on X to prevent outlier files-count from collapsing all others to the left
28428          var logMaxF=Math.log1p(maxF);
28429          var s='<svg class="scat-svg sx-b882cc83" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'"  xmlns="http://www.w3.org/2000/svg">';
28430          // Smooth the legend-hover fade so bubbles + labels animate together.
28431          s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
28432          // Y grid lines (linear)
28433          [0,0.25,0.5,0.75,1].forEach(function(t){
28434            var y=PT+cH*(1-t);
28435            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
28436            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>';
28437          });
28438          // X grid lines (log1p scale — tick labels show actual file counts at those positions)
28439          [0,0.25,0.5,0.75,1].forEach(function(t){
28440            var x=PL+cW*t;
28441            var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
28442            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
28443            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>';
28444          });
28445          // Full View (hOvr set) has the vertical room to show the per-bubble value
28446          // line; the compact card shows only the language label to avoid the
28447          // overlapping-label clutter seen when bubbles cluster together.
28448          var showVal=!!hOvr;
28449          SCAT_D.forEach(function(d,i){
28450            // X uses log1p so outlier languages (many files) don't push others to the far left
28451            var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
28452            var cy2=PT+cH-d.code/maxC*cH;
28453            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
28454            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"/>';
28455            // Label(s) centred directly above bubble; clamp to stay inside the plot top.
28456            if(showVal){
28457              var ty2=Math.max(24,px(cy2)-px(r)-3);
28458              var ty1=Math.max(12,ty2-14);
28459              s+='<text class="sx-c3270469" 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" >'+esc(d.lang)+'</text>';
28460              s+='<text class="sx-c3270469" 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" >'+fmt(d.code)+'</text>';
28461            }else{
28462              var ly2=Math.max(12,px(cy2)-px(r)-3);
28463              s+='<text class="sx-c3270469" 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" >'+esc(d.lang)+'</text>';
28464            }
28465          });
28466          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>';
28467          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>';
28468          // Legend (right side — top languages, max 2 columns, fills height)
28469          var legX=PL+cW+LG;
28470          var legBlockH=legPerCol*legRowH;
28471          var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
28472          function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
28473          for(var lk=0;lk<legShown;lk++){
28474            var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
28475            var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
28476            s+='<g class="sx-83ac1cee" data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" >';
28477            s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
28478            s+='<rect class="sx-c3270469" x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" />';
28479            s+='<text class="sx-c3270469" x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" >'+esc(ld.lang)+'</text>';
28480            s+='</g>';
28481          }
28482          if(legTrunc){
28483            var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
28484            s+='<g class="sx-83ac1cee" data-more="1" >';
28485            s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
28486            s+='<rect class="sx-c3270469" x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" />';
28487            s+='<text class="sx-c3270469" x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" >+'+(n-legShown)+' more</text>';
28488            s+='</g>';
28489          }
28490          s+='</svg>';
28491          el.innerHTML=s;
28492          wireScatterLegend(el);
28493          var moreEl=el.querySelector('g[data-more]');
28494          if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
28495        }
28496        renderScatterInEl(document.getElementById('r-scatter-chart'),0);
28497
28498        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
28499        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
28500        // the old vertical column layout on wide containers.
28501        function renderSemanticInEl(el,key,sh){
28502          if(!el||!SEM_D||!SEM_D.length)return;
28503          var n2=SEM_D.length||1;
28504          var LW=112,SH=sh||Math.max(180,n2*28+26);
28505          var svgW=Math.max(320,el.offsetWidth||480);
28506          var BW=Math.max(120,svgW-LW-80);
28507          var topPad=4,botPad=14;
28508          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
28509          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
28510          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
28511          var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'"  xmlns="http://www.w3.org/2000/svg">';
28512          SEM_D.forEach(function(d,i){
28513            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
28514            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>';
28515            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"/>';
28516            s+='<text class="sx-c3270469" x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" >'+fmt(v)+'</text>';
28517          });
28518          s+='</svg>';
28519          el.innerHTML=s;
28520        }
28521        function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
28522        var semSel=document.getElementById('r-semantic-metric');
28523        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
28524        var semExpand=document.getElementById('r-semantic-expand');
28525        if(semExpand){
28526          semExpand.addEventListener('click',function(){
28527            var key=semSel?semSel.value:'functions';
28528            var n=SEM_D.length||1;
28529            var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
28530            var modalH=Math.min(Math.max(360,n*38+60),maxH);
28531            var overlay=document.createElement('div');
28532            overlay.className='r-chart-modal-overlay';
28533            var optHtml=
28534              '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
28535              +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
28536              +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
28537              +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
28538              +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
28539            overlay.innerHTML='<div class="r-chart-modal sx-f4f5192a" ><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" data-sx-style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
28540            document.body.appendChild(overlay);
28541            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
28542            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
28543            var modalEl=document.getElementById('r-sem-modal-chart');
28544            if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
28545            var modalSel=document.getElementById('r-sem-modal-metric');
28546            if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
28547          });
28548        }
28549
28550        // ── Expand buttons: re-render charts at large size inside modal ──────────
28551        (function(){
28552          function makeExpandModal(title,mH,subtitle,ctrlHtml){
28553            var overlay=document.createElement('div');
28554            overlay.className='r-chart-modal-overlay';
28555            var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
28556            var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
28557            overlay.innerHTML='<div class="r-chart-modal sx-f4f5192a" ><button class="r-chart-modal-close" aria-label="Close">&times;</button>'+hdr+subHtml+'<div class="r-expand-modal-chart" data-sx-style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
28558            document.body.appendChild(overlay);
28559            overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
28560            overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
28561            return overlay.querySelector('.r-expand-modal-chart');
28562          }
28563          function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
28564          var compExpandBtn=document.getElementById('r-composition-expand');
28565          if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
28566            var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
28567            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
28568            var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
28569              +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
28570            var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
28571            if(wrap){
28572              setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
28573              Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
28574                btn.addEventListener('click',function(){
28575                  Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
28576                  btn.classList.add('active');
28577                  renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
28578                });
28579              });
28580            }
28581          });}
28582          var scatExpandBtn=document.getElementById('r-scatter-expand');
28583          if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
28584            var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
28585            if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
28586          });}
28587          var densExpandBtn=document.getElementById('r-density-expand');
28588          if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
28589            var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
28590            var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
28591            if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
28592          });}
28593          var avgExpandBtn=document.getElementById('r-avglines-expand');
28594          if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
28595            var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
28596            var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
28597            if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
28598          });}
28599          var subExpandBtn=document.getElementById('r-submodule-expand');
28600          if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
28601            var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
28602            var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
28603            var metCtrl=
28604              '<select class="r-chart-select" id="r-sub-modal-metric">'
28605              +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
28606              +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
28607              +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
28608              +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
28609              +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
28610              +'</select>';
28611            var sortCtrl=
28612              '<select class="r-chart-select" id="r-sub-modal-sort">'
28613              +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
28614              +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
28615              +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
28616              +'</select>';
28617            var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
28618            if(wrap){
28619              setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
28620              var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
28621              var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
28622              function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
28623              if(mSub)mSub.addEventListener('change',reRenderSub);
28624              if(mSort)mSort.addEventListener('change',reRenderSub);
28625            }
28626          });}
28627        })();
28628
28629        // ── Comment Density: comments / (code + comments) per language ───────────
28630        function renderDensityInEl(el,shOvr){
28631          if(!el||!LANG_D||!LANG_D.length)return;
28632          var n=LANG_D.length||1;
28633          var LW=112,SH=shOvr||Math.max(180,n*28+26);
28634          var svgW=Math.max(320,el.offsetWidth||480);
28635          var BW=Math.max(120,svgW-LW-80);
28636          var topPad=4,botPad=26;
28637          var rowTotal=Math.floor((SH-topPad-botPad)/n);
28638          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
28639          var densities=LANG_D.map(function(d){
28640            var sig=(d.code||0)+(d.comments||0);
28641            return sig>0?(d.comments||0)/sig:0;
28642          });
28643          var maxDen=Math.max.apply(null,densities)||1;
28644          var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'"  xmlns="http://www.w3.org/2000/svg">';
28645          LANG_D.forEach(function(d,i){
28646            var den=densities[i],bw=den/maxDen*BW;
28647            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
28648            var pct=Math.round(den*100);
28649            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>';
28650            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"/>';
28651            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
28652            s+='<text class="sx-c3270469" 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" >'+pct+'%</text>';
28653          });
28654          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>';
28655          s+='</svg>';
28656          el.innerHTML=s;
28657        }
28658        function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
28659        renderDensity();
28660
28661        // ── Avg Lines per File: code / files per language ─────────────────────
28662        function renderAvgLinesInEl(el,shOvr){
28663          if(!el||!LANG_D||!LANG_D.length)return;
28664          var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
28665          data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
28666          var n=data.length||1;
28667          var LW=112,SH=shOvr||Math.max(180,n*28+26);
28668          var svgW=Math.max(320,el.offsetWidth||480);
28669          var BW=Math.max(120,svgW-LW-80);
28670          var topPad=4,botPad=26;
28671          var rowTotal=Math.floor((SH-topPad-botPad)/n);
28672          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
28673          var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
28674          var maxAvg=Math.max.apply(null,avgs)||1;
28675          var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'"  xmlns="http://www.w3.org/2000/svg">';
28676          data.forEach(function(d,i){
28677            var avg=avgs[i],bw=avg/maxAvg*BW;
28678            var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
28679            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>';
28680            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"/>';
28681            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
28682            s+='<text class="sx-c3270469" 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" >'+fmt(Math.round(avg))+'</text>';
28683          });
28684          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>';
28685          s+='</svg>';
28686          el.innerHTML=s;
28687        }
28688        function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
28689        renderAvgLines();
28690
28691        // ── Repository Overview: overall row + per-submodule rows ────────────
28692        function renderSubmoduleInEl(el,key,sort,shOvr){
28693          if(!el)return;
28694          var overall={
28695            name:'Overall',
28696            code:{{ code_lines }},
28697            comment:{{ comment_lines }},
28698            blank:{{ blank_lines }},
28699            physical:{{ physical_lines }},
28700            files:{{ files_analyzed }},
28701            isOverall:true
28702          };
28703          var subs=SUB_D.slice();
28704          if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
28705          else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
28706          else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
28707          var data=[overall].concat(subs);
28708          var sepH=subs.length>0?14:0;
28709          var naturalH=data.length*32+sepH+16;
28710          var SH=shOvr||Math.max(100,naturalH);
28711          var svgW=Math.max(320,el.offsetWidth||480);
28712          var LW=116,BW=Math.max(200,svgW-LW-54);
28713          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
28714          var OVERALL_COL='#6b7280';
28715          var topPad=4,botPad=8;
28716          var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
28717          var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
28718          var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'"  xmlns="http://www.w3.org/2000/svg">';
28719          var yOff=topPad;
28720          data.forEach(function(d,i){
28721            var v=d[key]||0,bw=v/maxV*BW;
28722            var y=yOff+Math.floor((rowSlot-bH)/2);
28723            var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
28724            var label=d.name||d.path||'?';
28725            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>';
28726            if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
28727            else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
28728            s+='<text class="sx-c3270469" 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" >'+fmt(v)+'</text>';
28729            yOff+=rowSlot;
28730            if(d.isOverall&&subs.length>0){
28731              yOff+=sepH;
28732            }
28733          });
28734          s+='</svg>';
28735          el.innerHTML=s;
28736        }
28737        function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
28738        var subSel=document.getElementById('r-sub-metric');
28739        var sortSel=document.getElementById('r-sub-sort');
28740        renderSubmodule('code','desc');
28741        if(subSel){
28742          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
28743          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
28744        }
28745
28746        // Equalise heights within each chart row: if one chart in a grid row is taller
28747        // than its neighbour, re-render the shorter one at the taller height so bars fill
28748        // the available vertical space instead of leaving a gap.
28749        function syncRowHeights(){
28750          var avgEl=document.getElementById('r-avglines-chart');
28751          var subEl=document.getElementById('r-submodule-chart');
28752          if(avgEl&&subEl){
28753            var avgSvg=avgEl.querySelector('svg');
28754            var subSvg=subEl.querySelector('svg');
28755            if(avgSvg&&subSvg){
28756              var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
28757              var subH=parseInt(subSvg.getAttribute('height')||'0',10);
28758              var key=subSel?subSel.value||'code':'code';
28759              var sort=sortSel?sortSel.value:'desc';
28760              if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
28761              else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
28762            }
28763          }
28764          var semEl=document.getElementById('r-semantic-chart');
28765          var denEl=document.getElementById('r-density-chart');
28766          if(semEl&&denEl){
28767            var semSvg=semEl.querySelector('svg');
28768            var denSvg=denEl.querySelector('svg');
28769            if(semSvg&&denSvg){
28770              var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
28771              var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
28772              if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
28773              else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
28774            }
28775          }
28776        }
28777        syncRowHeights();
28778
28779        // Re-render all SVG charts when the window is resized so bars fill the card.
28780        var _rResizeTimer;
28781        window.addEventListener('resize',function(){
28782          clearTimeout(_rResizeTimer);
28783          _rResizeTimer=setTimeout(function(){
28784            var rcompBtn=document.querySelector('[data-rcomp].active');
28785            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
28786            renderScatterInEl(document.getElementById('r-scatter-chart'),0);
28787            if(semSel)renderSemantic(semSel.value||'functions');
28788            renderDensity();
28789            renderAvgLines();
28790            renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
28791            syncRowHeights();
28792          },120);
28793        });
28794      })();
28795
28796      (function randomizeWatermarks() {
28797        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
28798        if (!wms.length) return;
28799        var placed = [];
28800        function tooClose(top, left) {
28801          for (var i = 0; i < placed.length; i++) {
28802            var dt = Math.abs(placed[i][0] - top);
28803            var dl = Math.abs(placed[i][1] - left);
28804            if (dt < 20 && dl < 18) return true;
28805          }
28806          return false;
28807        }
28808        function pick(leftBand) {
28809          for (var attempt = 0; attempt < 50; attempt++) {
28810            var top = Math.random() * 85 + 5;
28811            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
28812            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
28813          }
28814          var top = Math.random() * 85 + 5;
28815          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
28816          placed.push([top, left]);
28817          return [top, left];
28818        }
28819        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
28820        var half = Math.floor(wms.length / 2);
28821        wms.forEach(function (img, i) {
28822          var pos = pick(i < half);
28823          var size = Math.floor(Math.random() * 100 + 160);
28824          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
28825          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
28826          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;
28827        });
28828      })();
28829
28830      (function spawnCodeParticles() {
28831        var container = document.getElementById('code-particles');
28832        if (!container) return;
28833        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
28834        for (var i = 0; i < 44; i++) {
28835          (function(idx) {
28836            var el = document.createElement('span');
28837            el.className = 'code-particle';
28838            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
28839            var left = Math.random() * 94 + 2;
28840            var top = Math.random() * 88 + 6;
28841            var dur = (Math.random() * 10 + 9).toFixed(1);
28842            var delay = (Math.random() * 18).toFixed(1);
28843            var rot = (Math.random() * 26 - 13).toFixed(1);
28844            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
28845            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';
28846            container.appendChild(el);
28847          })(i);
28848        }
28849      })();
28850
28851      {% if pdf_generating %}
28852      // Poll for PDF readiness and swap the disabled button to a live link once done.
28853      (function() {
28854        var openBtn = document.getElementById('pdf-open-btn');
28855        var dlBtn = document.getElementById('pdf-download-btn');
28856        function checkPdf() {
28857          fetch('/api/runs/{{ run_id }}/pdf-status')
28858            .then(function(r) { return r.json(); })
28859            .then(function(d) {
28860              if (d.ready) {
28861                if (openBtn) {
28862                  var a = document.createElement('a');
28863                  a.className = 'button';
28864                  a.id = 'pdf-open-btn';
28865                  a.href = '/runs/pdf/{{ run_id }}';
28866                  a.target = '_blank';
28867                  a.rel = 'noopener';
28868                  a.textContent = 'Open PDF';
28869                  openBtn.replaceWith(a);
28870                }
28871                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
28872              } else {
28873                setTimeout(checkPdf, 3000);
28874              }
28875            })
28876            .catch(function() { setTimeout(checkPdf, 5000); });
28877        }
28878        setTimeout(checkPdf, 3000);
28879      })();
28880      {% endif %}
28881
28882    })();
28883  </script>
28884  <script nonce="{{ csp_nonce }}">
28885  (function(){
28886    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'}];
28887    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);});}
28888    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28889    function init(){
28890      var btn=document.getElementById('settings-btn');if(!btn)return;
28891      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28892      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
28893      document.body.appendChild(m);
28894      var g=document.getElementById('scheme-grid');
28895      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);});
28896      var cl=document.getElementById('settings-close');
28897      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);});})();
28898      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');});
28899      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28900      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28901    }
28902    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28903  }());
28904  </script>
28905  <footer class="site-footer">
28906    local code analysis - metrics, history and reports
28907    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
28908    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28909    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28910    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28911    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
28912  </footer>
28913  {% if confluence_configured %}
28914  <script nonce="{{ csp_nonce }}">
28915  (function() {
28916    var postBtn = document.getElementById('postConfluenceBtn');
28917    var copyBtn = document.getElementById('copyWikiBtn');
28918    var modal   = document.getElementById('confluenceModal');
28919    if (!postBtn || !modal) return;
28920
28921    postBtn.addEventListener('click', function() {
28922      document.getElementById('confStatus').style.display = 'none';
28923      modal.style.display = 'flex';
28924    });
28925    document.getElementById('confCancelBtn').addEventListener('click', function() {
28926      modal.style.display = 'none';
28927    });
28928    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
28929
28930    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
28931      var btn = this;
28932      btn.disabled = true;
28933      var status = document.getElementById('confStatus');
28934      status.style.display = 'block';
28935      status.style.background = '#dbeafe';
28936      status.style.color = '#1e40af';
28937      status.textContent = 'Posting to Confluence\u2026';
28938      var resp = await fetch('/api/confluence/post', {
28939        method: 'POST',
28940        headers: { 'Content-Type': 'application/json' },
28941        body: JSON.stringify({
28942          run_id: '{{ run_id }}',
28943          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
28944          report_url: document.getElementById('confReportUrl').value.trim() || null
28945        })
28946      });
28947      var data = await resp.json();
28948      if (data.ok) {
28949        status.style.background = '#dcfce7'; status.style.color = '#166534';
28950        status.textContent = 'Posted! Page ID: ' + data.page_id;
28951      } else {
28952        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
28953        status.textContent = 'Error: ' + (data.error || 'Unknown error');
28954      }
28955      btn.disabled = false;
28956    });
28957
28958    if (copyBtn) {
28959      copyBtn.addEventListener('click', async function() {
28960        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
28961        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
28962        var text = await resp.text();
28963        try {
28964          await navigator.clipboard.writeText(text);
28965          var orig = copyBtn.textContent;
28966          copyBtn.textContent = 'Copied!';
28967          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
28968        } catch(e) {
28969          alert('Clipboard write failed \u2014 check browser permissions.');
28970        }
28971      });
28972    }
28973  })();
28974  </script>
28975  {% endif %}
28976  <script nonce="{{ csp_nonce }}">
28977  (function() {
28978    var deleteBtn = document.getElementById('delete-run-btn');
28979    var modal     = document.getElementById('delete-run-modal');
28980    var cancelBtn = document.getElementById('delete-run-cancel');
28981    var confirmBtn= document.getElementById('delete-run-confirm');
28982    if (!deleteBtn || !modal) return;
28983    deleteBtn.addEventListener('click', function() {
28984      document.getElementById('delete-run-status').style.display = 'none';
28985      modal.style.display = 'flex';
28986    });
28987    cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
28988    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
28989    confirmBtn.addEventListener('click', async function() {
28990      confirmBtn.disabled = true;
28991      cancelBtn.disabled = true;
28992      var status = document.getElementById('delete-run-status');
28993      status.style.display = 'block';
28994      status.style.background = '#dbeafe'; status.style.color = '#1e40af';
28995      status.textContent = 'Deleting\u2026';
28996      try {
28997        var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
28998        if (resp.status === 204 || resp.ok) {
28999          status.style.background = '#dcfce7'; status.style.color = '#166534';
29000          status.textContent = 'Deleted. Redirecting\u2026';
29001          setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
29002        } else {
29003          var d = await resp.json().catch(function(){return {};});
29004          status.style.background = '#fee2e2'; status.style.color = '#991b1b';
29005          status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
29006          confirmBtn.disabled = false;
29007          cancelBtn.disabled = false;
29008        }
29009      } catch (e) {
29010        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
29011        status.textContent = 'Network error: ' + String(e);
29012        confirmBtn.disabled = false;
29013        cancelBtn.disabled = false;
29014      }
29015    });
29016  })();
29017  </script>
29018  <script nonce="{{ csp_nonce }}">(function(){
29019    var bundleBtn = document.getElementById('download-bundle-btn');
29020    if (bundleBtn) {
29021      bundleBtn.addEventListener('click', function() {
29022        bundleBtn.disabled = true;
29023        var orig = bundleBtn.textContent;
29024        bundleBtn.textContent = 'Preparing\u2026';
29025        fetch('/api/runs/{{ run_id }}/bundle')
29026          .then(function(r) {
29027            if (!r.ok) throw new Error('HTTP ' + r.status);
29028            return r.blob();
29029          })
29030          .then(function(blob) {
29031            var url = URL.createObjectURL(blob);
29032            var a = document.createElement('a');
29033            a.href = url;
29034            a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
29035            document.body.appendChild(a);
29036            a.click();
29037            setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
29038            bundleBtn.disabled = false;
29039            bundleBtn.textContent = orig;
29040          })
29041          .catch(function(e) {
29042            bundleBtn.disabled = false;
29043            bundleBtn.textContent = orig;
29044            alert('Bundle download failed: ' + String(e));
29045          });
29046      });
29047    }
29048  })();</script>
29049  <script nonce="{{ csp_nonce }}">(function(){
29050    var dot=document.getElementById('status-dot');
29051    var pingEl=document.getElementById('server-ping-ms');
29052    var tipEl=document.getElementById('server-tip-ping');
29053    var fm=document.getElementById('footer-mode');
29054    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)';}}
29055    function doPing(){
29056      var t0=performance.now();
29057      fetch('/healthz',{cache:'no-store'})
29058        .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);})
29059        .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)';}});
29060    }
29061    doPing();
29062    setInterval(doPing,5000);
29063    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');}
29064  })();</script>
29065  <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>
29066  {% if let Some(banner) = report_header_footer %}
29067  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
29068  {% endif %}
29069</body>
29070</html>
29071"##,
29072    ext = "html"
29073)]
29074// Template structs need many bool fields to pass Askama rendering flags.
29075#[allow(clippy::struct_excessive_bools)]
29076struct ResultTemplate {
29077    version: &'static str,
29078    report_title: String,
29079    project_path: String,
29080    output_dir: String,
29081    run_id: String,
29082    files_analyzed: u64,
29083    files_skipped: u64,
29084    physical_lines: u64,
29085    code_lines: u64,
29086    comment_lines: u64,
29087    blank_lines: u64,
29088    mixed_lines: u64,
29089    functions: u64,
29090    classes: u64,
29091    variables: u64,
29092    imports: u64,
29093    html_url: Option<String>,
29094    pdf_url: Option<String>,
29095    json_url: Option<String>,
29096    html_download_url: Option<String>,
29097    pdf_download_url: Option<String>,
29098    json_download_url: Option<String>,
29099    html_path: Option<String>,
29100    json_path: Option<String>,
29101    prev_run_id: Option<String>,
29102    prev_run_timestamp: Option<String>,
29103    prev_run_code_lines: Option<u64>,
29104    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
29105    prev_fa_str: String,
29106    prev_fs_str: String,
29107    prev_pl_str: String,
29108    prev_cl_str: String,
29109    prev_cml_str: String,
29110    prev_bl_str: String,
29111    // Signed change column for main metrics
29112    delta_fa_str: String,
29113    delta_fa_class: String,
29114    delta_fs_str: String,
29115    delta_fs_class: String,
29116    delta_pl_str: String,
29117    delta_pl_class: String,
29118    delta_cl_str: String,
29119    delta_cl_class: String,
29120    delta_cml_str: String,
29121    delta_cml_class: String,
29122    delta_bl_str: String,
29123    delta_bl_class: String,
29124    // delta vs previous scan
29125    delta_lines_added: Option<i64>,
29126    delta_lines_removed: Option<i64>,
29127    delta_lines_net_str: String,
29128    delta_lines_net_class: String,
29129    delta_files_added: Option<usize>,
29130    delta_files_removed: Option<usize>,
29131    delta_files_modified: Option<usize>,
29132    delta_files_unchanged: Option<usize>,
29133    delta_files_total: Option<usize>,
29134    delta_unmodified_lines: Option<u64>,
29135    // git context
29136    git_branch: Option<String>,
29137    git_branch_url: Option<String>,
29138    git_commit: Option<String>,
29139    git_commit_long: Option<String>,
29140    git_author: Option<String>,
29141    git_commit_url: Option<String>,
29142    // scan metadata for hero section
29143    scan_performed_by: String,
29144    scan_time_display: String,
29145    scan_time_utc_ms: i64,
29146    os_display: String,
29147    test_count: u64,
29148    // reserve "pad" card, revealed by JS only when the visible card count is odd
29149    test_assertion_count: u64,
29150    // history
29151    prev_scan_count: usize,
29152    current_scan_number: usize,
29153    // submodule breakdown (empty when not requested)
29154    submodule_rows: Vec<SubmoduleRow>,
29155    scan_config_url: String,
29156    lang_chart_json: String,
29157    // Askama reads these via proc-macro expansion; clippy can't trace through it.
29158    #[allow(dead_code)]
29159    scatter_chart_json: String,
29160    #[allow(dead_code)]
29161    semantic_chart_json: String,
29162    #[allow(dead_code)]
29163    submodule_chart_json: String,
29164    #[allow(dead_code)]
29165    has_submodule_data: bool,
29166    #[allow(dead_code)]
29167    has_semantic_data: bool,
29168    pdf_generating: bool,
29169    csp_nonce: String,
29170    /// Whether Confluence integration is configured — shows Post button when true.
29171    confluence_configured: bool,
29172    server_mode: bool,
29173    /// Header/footer identification banner, mirrored from the HTML/PDF report.
29174    report_header_footer: Option<String>,
29175    run_id_short: String,
29176    /// True when rendering a static offline file (index.html); hides server-only actions.
29177    #[allow(dead_code)]
29178    is_offline: bool,
29179    /// Total cyclomatic complexity score across all analyzed files.
29180    cyclomatic_complexity: u64,
29181    /// Logical SLOC (statement count) when available; None for unsupported languages.
29182    lsloc: Option<u64>,
29183    /// Unique Lines of Code across all analyzed files.
29184    uloc: u64,
29185    /// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
29186    dryness_pct_str: String,
29187    /// Number of duplicate file groups detected.
29188    duplicate_group_count: usize,
29189    /// Whether a COCOMO estimate is available to display.
29190    has_cocomo: bool,
29191    /// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
29192    cocomo_effort_str: String,
29193    /// Pre-formatted COCOMO schedule (months), e.g. "6.18".
29194    cocomo_duration_str: String,
29195    /// Pre-formatted average team size, e.g. "2.32".
29196    cocomo_staff_str: String,
29197    /// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
29198    cocomo_ksloc_str: String,
29199    /// COCOMO mode label shown in the card (e.g. "Organic").
29200    cocomo_mode_label: String,
29201    /// Tooltip text explaining the selected COCOMO mode.
29202    cocomo_mode_tooltip: String,
29203    /// Per-file complexity alert threshold. 0 = off (no highlighting).
29204    complexity_alert: u32,
29205    /// Whether any file has coverage data attached.
29206    has_coverage_data: bool,
29207    /// Overall line coverage percentage string, e.g. "87.3" — empty if no data.
29208    cov_line_pct: String,
29209    /// Overall function coverage percentage string — empty if no data.
29210    cov_fn_pct: String,
29211    /// Overall branch coverage percentage string — empty if no branch data.
29212    cov_branch_pct: String,
29213    /// Lines hit / lines found summary, e.g. "1 247 / 1 432" — empty if no data.
29214    cov_lines_summary: String,
29215    /// Pre-rendered Code Ownership panel (contributor table + Combine-contributors merge UI).
29216    /// Empty when the run has no blame attribution or on the static offline mirror.
29217    ownership_html: String,
29218}
29219
29220#[derive(Template)]
29221#[template(
29222    source = r##"
29223<!doctype html>
29224<html lang="en">
29225<head>
29226  <meta charset="utf-8">
29227  <meta name="viewport" content="width=device-width, initial-scale=1">
29228  <title>OxideSLOC | Analyzing…</title>
29229  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29230  <link rel="stylesheet" href="/static/app.css">
29231  <script src="/static/app.js"></script>
29232  <style nonce="{{ csp_nonce }}">
29233    :root {
29234      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
29235      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
29236      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
29237      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
29238    }
29239    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
29240    *{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;}
29241    .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);}
29242    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
29243    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
29244    .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));}
29245    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29246    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
29247    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
29248    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
29249    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29250    @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; } }
29251    .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;}
29252    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
29253    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
29254    .page-body{padding:32px 24px 36px;}
29255    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
29256    .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;}
29257    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
29258    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
29259    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
29260    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
29261    .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;}
29262    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
29263    .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;}
29264    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
29265    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
29266    .overall-wrap{margin-bottom:16px;}
29267    .overall-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px;}
29268    .overall-label{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;}
29269    .overall-pct{font-size:14px;font-weight:900;color:var(--oxide);font-variant-numeric:tabular-nums;}
29270    .overall-track{height:9px;border-radius:999px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden;}
29271    .overall-fill{height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg,var(--accent-2),var(--oxide));transition:width .4s ease;}
29272    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
29273    .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;}
29274    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
29275    .hidden{display:none!important;}
29276    .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;}
29277    .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;}
29278    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
29279    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
29280    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
29281    .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);}
29282    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
29283    .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;}
29284    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
29285    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29286    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29287    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
29288    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29289    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
29290    @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));}}
29291    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29292    .site-footer a{color:var(--muted);}
29293    .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;}
29294    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
29295    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
29296    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
29297  </style>
29298</head>
29299<body>
29300  <div class="background-watermarks" aria-hidden="true">
29301    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29302    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29303    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29304    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29305    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29306    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29307  </div>
29308  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29309  <nav class="top-nav">
29310    <div class="top-nav-inner">
29311      <a href="/" class="brand">
29312        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
29313        <div class="brand-copy">
29314          <h1 class="brand-title">OxideSLOC</h1>
29315          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
29316        </div>
29317      </a>
29318      <div class="nav-right">
29319        <a class="nav-pill" href="/">Home</a>
29320        <div class="nav-dropdown">
29321          <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>
29322          <div class="nav-dropdown-menu">
29323            <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>
29324          </div>
29325        </div>
29326        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
29327        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29328        <div class="nav-dropdown">
29329          <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>
29330          <div class="nav-dropdown-menu">
29331            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
29332            <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>
29333          </div>
29334        </div>
29335        <div class="server-status-wrap" id="server-status-wrap">
29336          <div class="nav-pill server-online-pill" id="server-status-pill">
29337            <span class="status-dot" id="status-dot"></span>
29338            <span id="server-status-label">Server</span>
29339            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
29340          </div>
29341          <div class="server-status-tip">
29342            OxideSLOC is running — accessible on your network.
29343            <span class="sx-238af6bc" id="server-tip-ping" ></span>
29344          </div>
29345        </div>
29346        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29347          <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>
29348        </button>
29349        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29350          <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>
29351          <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>
29352        </button>
29353      </div>
29354    </div>
29355  </nav>
29356  <div class="page-body">
29357    <div class="wait-panel">
29358      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
29359      <h2 class="wait-title">Analyzing your project…</h2>
29360      <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
29361      <div class="path-block">{{ project_path }}</div>
29362      <div class="metrics-row">
29363        <div class="metric-card">
29364          <div class="metric-label">Elapsed</div>
29365          <div class="metric-value" id="elapsed">0s</div>
29366        </div>
29367        <div class="metric-card">
29368          <div class="metric-label">Phase</div>
29369          <div class="metric-value" id="phase">Starting</div>
29370        </div>
29371        <div class="metric-card hidden" id="files-card">
29372          <div class="metric-label">Files</div>
29373          <div class="metric-value" id="files-progress">0</div>
29374        </div>
29375      </div>
29376      <div class="overall-wrap">
29377        <div class="overall-head"><span class="overall-label">Overall progress</span><span class="overall-pct" id="overall-pct">0%</span></div>
29378        <div class="overall-track"><div class="overall-fill" id="overall-fill"></div></div>
29379      </div>
29380      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
29381      <div class="warn-slow hidden" id="warn-slow">
29382        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.
29383      </div>
29384      <div class="err-panel hidden" id="err-panel">
29385        <strong>Analysis failed</strong>
29386        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
29387      </div>
29388      <div class="actions hidden" id="actions">
29389        <a href="/scan" class="btn-primary">Try Again</a>
29390        <a href="/view-reports" class="btn-outline">View Reports</a>
29391      </div>
29392    </div>
29393  </div>
29394  <script nonce="{{ csp_nonce }}">
29395    (function() {
29396      var WAIT_ID = {{ wait_id_json|safe }};
29397      var startTime = Date.now();
29398      var pollInterval = 1500;
29399      var retries = 0;
29400      var maxRetries = 5;
29401      var warnShown = false;
29402
29403      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();}
29404
29405      function elapsed() {
29406        return Math.floor((Date.now() - startTime) / 1000);
29407      }
29408
29409      function updateElapsed() {
29410        var s = elapsed();
29411        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
29412      }
29413
29414      function setPhase(txt) {
29415        document.getElementById('phase').textContent = txt;
29416      }
29417
29418      // Overall-progress bands weighted by real time cost (attribution dominates large repos).
29419      var PHASE_BAND = {
29420        'Starting':[0,2],'Scanning files':[2,15],'Running':[2,15],
29421        'Summarizing submodules':[15,18],'Computing metrics':[18,20],'Reading git history':[20,22],
29422        'Attributing authorship':[22,97],'Writing reports':[97,99],'Done':[100,100],'Failed':[100,100]
29423      };
29424      var lastPct = 0;
29425      function setOverall(data, forceDone) {
29426        var pct;
29427        if (forceDone) { pct = 100; }
29428        else {
29429          var phase = (data && data.phase) || 'Starting';
29430          var band = PHASE_BAND[phase] || [lastPct, lastPct];
29431          var frac = 1;
29432          if (phase === 'Scanning files' || phase === 'Running') {
29433            frac = (data.files_total > 0) ? (data.files_done / data.files_total) : 0;
29434          } else if (phase === 'Attributing authorship') {
29435            frac = (data.attrib_total > 0) ? (data.attrib_done / data.attrib_total) : 0;
29436          }
29437          pct = band[0] + (band[1] - band[0]) * Math.max(0, Math.min(1, frac));
29438        }
29439        lastPct = Math.max(lastPct, pct);
29440        var p = Math.max(0, Math.min(100, Math.round(lastPct)));
29441        var fill = document.getElementById('overall-fill');
29442        var lbl = document.getElementById('overall-pct');
29443        if (fill) fill.style.width = p + '%';
29444        if (lbl) lbl.textContent = p + '%';
29445      }
29446
29447      var elapsedTimer = setInterval(updateElapsed, 1000);
29448
29449      function poll() {
29450        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
29451          .then(function(r) {
29452            if (!r.ok) throw new Error('HTTP ' + r.status);
29453            return r.json();
29454          })
29455          .then(function(data) {
29456            retries = 0;
29457            if (data.state === 'complete') {
29458              clearInterval(elapsedTimer);
29459              setPhase('Done');
29460              setOverall(data, true);
29461              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
29462            } else if (data.state === 'failed') {
29463              clearInterval(elapsedTimer);
29464              setPhase('Failed');
29465              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
29466              document.getElementById('err-panel').classList.remove('hidden');
29467              document.getElementById('actions').classList.remove('hidden');
29468            } else {
29469              // still running
29470              var s = elapsed();
29471              if (s > 90 && !warnShown) {
29472                warnShown = true;
29473                document.getElementById('warn-slow').classList.remove('hidden');
29474              }
29475              setPhase(data.phase || 'Running');
29476              // Switch the live counter to blame progress during the attribution pass so the page
29477              // shows movement instead of a frozen "files done / total" for the slowest stage.
29478              var attribActive = (data.attrib_total || 0) > 0;
29479              var curDone = attribActive ? (data.attrib_done || 0) : (data.files_done || 0);
29480              var curTotal = attribActive ? (data.attrib_total || 0) : (data.files_total || 0);
29481              if (curTotal > 0) {
29482                var card = document.getElementById('files-card');
29483                if (card) card.classList.remove('hidden');
29484                var lbl = card ? card.querySelector('.metric-label') : null;
29485                if (lbl) lbl.textContent = attribActive ? 'Blamed' : 'Files';
29486                var fp = document.getElementById('files-progress');
29487                if (fp) fp.textContent = fmt(curDone) + ' / ' + fmt(curTotal);
29488              }
29489              if (attribActive && !warnShown) {
29490                warnShown = true;
29491                var ws = document.getElementById('warn-slow');
29492                if (ws) {
29493                  ws.textContent = 'Attributing authorship: running git blame on every source file across the repo and all submodules. This is the slowest stage and scales with file count — the counter above shows live progress.';
29494                  ws.classList.remove('hidden');
29495                }
29496              }
29497              setOverall(data, false);
29498              setTimeout(poll, pollInterval);
29499            }
29500          })
29501          .catch(function(err) {
29502            retries++;
29503            if (retries >= maxRetries) {
29504              clearInterval(elapsedTimer);
29505              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
29506              document.getElementById('err-panel').classList.remove('hidden');
29507              document.getElementById('actions').classList.remove('hidden');
29508            } else {
29509              // exponential back-off capped at 8s
29510              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
29511            }
29512          });
29513      }
29514
29515      setTimeout(poll, pollInterval);
29516
29517      // If the browser restores this page from bfcache (Back after viewing results),
29518      // timers may be frozen; kick off a fresh poll so we either redirect or resume.
29519      window.addEventListener("pageshow", function(e) {
29520        if (e.persisted) { setTimeout(poll, 200); }
29521      });
29522    })();
29523  </script>
29524  <footer class="site-footer">
29525    local code analysis - metrics, history and reports
29526    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
29527    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29528    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29529    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29530    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29531  </footer>
29532  <script nonce="{{ csp_nonce }}">
29533    (function(){
29534      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
29535      if(s==="dark")b.classList.add("dark-theme");
29536      var tt=document.getElementById("theme-toggle");
29537      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
29538    })();
29539    (function spawnCodeParticles(){
29540      var c=document.getElementById('code-particles');if(!c)return;
29541      var sn = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
29542      for(var i=0;i<38;i++){(function(idx){
29543        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
29544        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
29545        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
29546        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random() * 0.108 + 0.072).toFixed(3);
29547        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
29548        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
29549        c.appendChild(el);
29550      })(i);}
29551    })();
29552    (function randomizeWatermarks(){
29553      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29554      var placed=[];
29555      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;}
29556      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];}
29557      var half=Math.floor(wms.length/2);
29558      wms.forEach(function(img,i){
29559        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
29560        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
29561        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
29562        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
29563        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
29564        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
29565      });
29566    })();
29567  </script>
29568  <script nonce="{{ csp_nonce }}">
29569  (function(){
29570    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'}];
29571    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);});}
29572    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29573    function init(){
29574      var btn=document.getElementById('settings-btn');if(!btn)return;
29575      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29576      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
29577      document.body.appendChild(m);
29578      var g=document.getElementById('scheme-grid');
29579      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);});
29580      var cl=document.getElementById('settings-close');
29581      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);});})();
29582      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');});
29583      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29584      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29585    }
29586    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29587  }());
29588  </script>
29589  <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]';
29590  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;}
29591  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>
29592</body>
29593</html>
29594"##,
29595    ext = "html"
29596)]
29597struct ScanWaitTemplate {
29598    version: &'static str,
29599    wait_id_json: String,
29600    project_path: String,
29601    csp_nonce: String,
29602}
29603
29604#[derive(Template)]
29605#[template(
29606    source = r##"
29607<!doctype html>
29608<html lang="en">
29609<head>
29610  <meta charset="utf-8">
29611  <meta name="viewport" content="width=device-width, initial-scale=1">
29612  <title>OxideSLOC | Error</title>
29613  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29614  <link rel="stylesheet" href="/static/app.css">
29615  <script src="/static/app.js"></script>
29616  <style nonce="{{ csp_nonce }}">
29617    :root {
29618      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
29619      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
29620      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
29621      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
29622    }
29623    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
29624    *{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;}
29625    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29626    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29627    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
29628    .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);}
29629    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
29630    .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));}
29631    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29632    .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;}
29633    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
29634    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29635    @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; } }
29636    .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;}
29637    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
29638    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
29639    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29640    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29641    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29642    .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;}
29643    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29644    .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);}
29645    .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;}
29646    .settings-close:hover{color:var(--text);background:var(--surface-2);}
29647    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29648    .settings-modal-body{padding:14px 16px 16px;}
29649    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29650    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29651    .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;}
29652    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29653    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29654    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29655    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29656    .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;}
29657    .tz-select:focus{border-color:var(--oxide);}
29658    .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
29659    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29660    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
29661    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
29662    .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;}
29663    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
29664    .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);}
29665    .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;}
29666    .btn-secondary:hover{background:var(--line);}
29667    .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
29668    .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;}
29669    .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;}
29670    .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
29671    .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
29672    .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
29673    .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
29674    .bug-report-panel.open{display:flex;}
29675    .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;}
29676    .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
29677    .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
29678    body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
29679    body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
29680    .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
29681    .br-network-badge.online .br-net-dot{background:#2a6846;}
29682    .br-network-badge.offline .br-net-dot{background:#9a5b00;}
29683    body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
29684    body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
29685    .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;}
29686    .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
29687    .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;}
29688    .btn-sm:hover{background:var(--line);}
29689    .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
29690    .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
29691    .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
29692    .bug-report-hint a:hover{text-decoration:underline;}
29693    .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;}
29694    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
29695    .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;}
29696    .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;}
29697    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
29698    @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));}}
29699    .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;}
29700  </style>
29701</head>
29702<body>
29703  <div class="background-watermarks" aria-hidden="true">
29704    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29705    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29706    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29707    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29708    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29709    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29710  </div>
29711  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29712  <div class="top-nav">
29713    <div class="top-nav-inner">
29714      <a class="brand" href="/">
29715        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
29716        <div class="brand-copy">
29717          <div class="brand-title">OxideSLOC</div>
29718          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
29719        </div>
29720      </a>
29721      <div class="nav-right">
29722        <a class="nav-pill" href="/">Home</a>
29723        <div class="nav-dropdown">
29724          <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>
29725          <div class="nav-dropdown-menu">
29726            <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>
29727          </div>
29728        </div>
29729        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
29730        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29731        <div class="nav-dropdown">
29732          <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>
29733          <div class="nav-dropdown-menu">
29734            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
29735            <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>
29736          </div>
29737        </div>
29738        <div class="server-status-wrap" id="server-status-wrap">
29739          <div class="nav-pill server-online-pill" id="server-status-pill">
29740            <span class="status-dot" id="status-dot"></span>
29741            <span id="server-status-label">Server</span>
29742            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
29743          </div>
29744          <div class="server-status-tip">
29745            OxideSLOC is running — accessible on your network.
29746            <span class="sx-238af6bc" id="server-tip-ping" ></span>
29747          </div>
29748        </div>
29749        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29750          <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>
29751        </button>
29752        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29753          <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>
29754          <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>
29755        </button>
29756      </div>
29757    </div>
29758  </div>
29759
29760  <div class="page">
29761    <div class="panel">
29762      <h1>Error</h1>
29763      <div class="error-box" id="error-msg-text">{{ message }}</div>
29764      <div id="br-meta" hidden
29765        data-version="{{ version }}"
29766        data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
29767        data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
29768      <div class="actions">
29769        <a class="btn-primary" href="/scan">Back to setup</a>
29770        {% if let Some(report_url) = last_report_url %}
29771        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
29772        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
29773        {% else %}
29774        <a class="btn-secondary" href="/view-reports">View Reports</a>
29775        {% endif %}
29776      </div>
29777      <div class="bug-report-section" id="bug-report-section">
29778        <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
29779          <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>
29780          Generate Bug Report
29781          <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
29782        </button>
29783        <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
29784          <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking&hellip;</span></div>
29785          <pre class="bug-report-pre" id="bug-report-pre">Collecting info&hellip;</pre>
29786          <div class="bug-report-btns">
29787            <button type="button" class="btn-sm" id="bug-report-copy">
29788              <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>
29789              Copy to clipboard
29790            </button>
29791            <a class="btn-sm sx-d0466aa3" id="bug-report-github-link" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer" >
29792              <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>
29793              Open GitHub Issue
29794            </a>
29795            <button type="button" class="btn-sm sx-d0466aa3" id="bug-report-save" >
29796              <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>
29797              Save as file
29798            </button>
29799          </div>
29800          <p class="bug-report-hint sx-d0466aa3" id="br-hint-online" >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>
29801          <p class="bug-report-hint sx-d0466aa3" id="br-hint-offline" ><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>
29802        </div>
29803      </div>
29804    </div>
29805  </div>
29806  <footer class="site-footer">
29807    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
29808    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29809    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29810    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29811    &nbsp;&middot;&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
29812  </footer>
29813  <script nonce="{{ csp_nonce }}">(function(){
29814    var meta=document.getElementById('br-meta');
29815    var pre=document.getElementById('bug-report-pre');
29816    var copyBtn=document.getElementById('bug-report-copy');
29817    var trigger=document.getElementById('bug-report-trigger');
29818    var panel=document.getElementById('bug-report-panel');
29819    var networkBadge=document.getElementById('br-network-badge');
29820    var networkLabel=document.getElementById('br-network-label');
29821    var ghLink=document.getElementById('bug-report-github-link');
29822    var saveBtn=document.getElementById('bug-report-save');
29823    var hintOnline=document.getElementById('br-hint-online');
29824    var hintOffline=document.getElementById('br-hint-offline');
29825    if(!meta||!pre)return;
29826    var ver=meta.getAttribute('data-version')||'';
29827    var runId=meta.getAttribute('data-run-id')||'';
29828    var code=meta.getAttribute('data-error-code')||'';
29829    var msgEl=document.getElementById('error-msg-text');
29830    var msg=msgEl?msgEl.textContent.trim():'';
29831    function getBrowser(){
29832      var ua=navigator.userAgent;
29833      var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
29834      if(!m)return 'Unknown browser';
29835      var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
29836      return n+' '+m[2];
29837    }
29838    var lines=['oxide-sloc Bug Report','==============================',''];
29839    lines.push('App version:  v'+ver);
29840    if(code)lines.push('HTTP status:  '+code);
29841    if(runId)lines.push('Run ID:       '+runId);
29842    lines.push('Page:         '+window.location.pathname+(window.location.search||''));
29843    lines.push('Timestamp:    '+new Date().toISOString());
29844    lines.push('Browser:      '+getBrowser());
29845    lines.push('Viewport:     '+window.innerWidth+'x'+window.innerHeight);
29846    lines.push('');
29847    lines.push('Error message:');
29848    lines.push(msg);
29849    lines.push('');
29850    lines.push('Steps to reproduce:');
29851    lines.push('  1. ');
29852    lines.push('');
29853    lines.push('Expected behavior:');
29854    lines.push('  ');
29855    pre.textContent=lines.join('\n');
29856    function applyNetwork(online){
29857      if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
29858      if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
29859      if(ghLink){
29860        if(online){
29861          var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
29862          ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
29863        }
29864        ghLink.style.display=online?'inline-flex':'none';
29865      }
29866      if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
29867      if(hintOnline)hintOnline.style.display=online?'block':'none';
29868      if(hintOffline)hintOffline.style.display=online?'none':'block';
29869    }
29870    applyNetwork(navigator.onLine);
29871    var probed=false;
29872    function probeNetwork(){
29873      if(probed)return;probed=true;
29874      var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
29875      var probeIdx=0;
29876      function tryNext(){
29877        if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
29878        var u=probeUrls[probeIdx++];
29879        var c2=new AbortController();
29880        var t2=setTimeout(function(){c2.abort();},4000);
29881        fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
29882          .then(function(){clearTimeout(t2);applyNetwork(true);})
29883          .catch(function(){clearTimeout(t2);tryNext();});
29884      }
29885      tryNext();
29886    }
29887    if(trigger&&panel){
29888      trigger.addEventListener('click',function(){
29889        var open=panel.classList.toggle('open');
29890        trigger.classList.toggle('open',open);
29891        trigger.setAttribute('aria-expanded',open?'true':'false');
29892        if(open)probeNetwork();
29893      });
29894    }
29895    if(copyBtn){
29896      copyBtn.addEventListener('click',function(){
29897        var txt=pre.textContent;
29898        if(navigator.clipboard&&navigator.clipboard.writeText){
29899          navigator.clipboard.writeText(txt).then(function(){
29900            copyBtn.textContent='\u2713 Copied!';
29901            setTimeout(function(){copyBtn.innerHTML='<svg class="sx-78514aa8" 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> Copy to clipboard';},2000);
29902          });
29903        }else{
29904          var ta=document.createElement('textarea');
29905          ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
29906          document.body.appendChild(ta);ta.select();
29907          try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
29908          document.body.removeChild(ta);
29909        }
29910      });
29911    }
29912    if(saveBtn){
29913      saveBtn.addEventListener('click',function(){
29914        var txt=pre.textContent;
29915        var blob=new Blob([txt],{type:'text/plain'});
29916        var url=URL.createObjectURL(blob);
29917        var a=document.createElement('a');
29918        a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
29919        document.body.appendChild(a);a.click();
29920        document.body.removeChild(a);URL.revokeObjectURL(url);
29921      });
29922    }
29923  })();</script>
29924  <script nonce="{{ csp_nonce }}">
29925    (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");});})();
29926    (function spawnCodeParticles() {
29927      var container = document.getElementById('code-particles');
29928      if (!container) return;
29929      var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
29930      for (var i = 0; i < 44; i++) {
29931        (function(idx) {
29932          var el = document.createElement('span');
29933          el.className = 'code-particle';
29934          el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
29935          var left = Math.random() * 94 + 2;
29936          var top = Math.random() * 88 + 6;
29937          var dur = (Math.random() * 10 + 9).toFixed(1);
29938          var delay = (Math.random() * 18).toFixed(1);
29939          var rot = (Math.random() * 26 - 13).toFixed(1);
29940          var op = (Math.random() * 0.108 + 0.072).toFixed(3);
29941          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';
29942          container.appendChild(el);
29943        })(i);
29944      }
29945    })();
29946    (function randomizeWatermarks() {
29947      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29948      var placed = [];
29949      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; }
29950      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]; }
29951      var half = Math.floor(wms.length/2);
29952      wms.forEach(function(img, i) {
29953        var pos = pick(i < half);
29954        var w = Math.floor(Math.random()*60+80);
29955        var rot = (Math.random()*40-20).toFixed(1);
29956        var op = (Math.random()*0.08+0.05).toFixed(2);
29957        var animDur = (Math.random()*6+5).toFixed(1);
29958        var animDelay = (Math.random()*10).toFixed(1);
29959        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';
29960      });
29961    })();
29962  </script>
29963  <script nonce="{{ csp_nonce }}">
29964  (function(){
29965    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'}];
29966    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);});}
29967    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29968    function init(){
29969      var btn=document.getElementById('settings-btn');if(!btn)return;
29970      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29971      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
29972      document.body.appendChild(m);
29973      var g=document.getElementById('scheme-grid');
29974      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);});
29975      var cl=document.getElementById('settings-close');
29976      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);});})();
29977      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');});
29978      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29979      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29980    }
29981    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29982  }());
29983  </script>
29984  <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]';
29985  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;}
29986  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>
29987</body>
29988</html>
29989"##,
29990    ext = "html"
29991)]
29992struct ErrorTemplate {
29993    message: String,
29994    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
29995    last_report_url: Option<String>,
29996    /// Label for the secondary action button; defaults to "View last report" when None.
29997    last_report_label: Option<String>,
29998    /// Run ID to surface in the bug report; `None` when not applicable.
29999    run_id: Option<String>,
30000    /// HTTP status code to surface in the bug report; `None` when unknown.
30001    error_code: Option<u16>,
30002    csp_nonce: String,
30003    version: &'static str,
30004}
30005
30006// ── LocateFileTemplate ────────────────────────────────────────────────────────
30007
30008#[derive(Template)]
30009#[template(
30010    source = r##"
30011<!doctype html>
30012<html lang="en">
30013<head>
30014  <meta charset="utf-8">
30015  <meta name="viewport" content="width=device-width, initial-scale=1">
30016  <title>OxideSLOC | Locate Report</title>
30017  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30018  <link rel="stylesheet" href="/static/app.css">
30019  <script src="/static/app.js"></script>
30020  <style nonce="{{ csp_nonce }}">
30021    :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);}
30022    body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
30023    *{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;}
30024    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30025    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30026    .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);}
30027    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
30028    .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));}
30029    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
30030    .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;}
30031    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
30032    @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
30033    @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;}}
30034    .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;}
30035    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
30036    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
30037    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
30038    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30039    .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
30040    .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;}
30041    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30042    .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);}
30043    .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;}
30044    .settings-close:hover{color:var(--text);background:var(--surface-2);}
30045    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
30046    .settings-modal-body{padding:14px 16px 16px;}
30047    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30048    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30049    .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;}
30050    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30051    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30052    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30053    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30054    .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;}
30055    .tz-select:focus{border-color:var(--oxide);}
30056    .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
30057    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
30058    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
30059    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
30060    .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
30061    .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;}
30062    .filename-chip svg{flex:0 0 auto;opacity:0.6;}
30063    .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
30064    .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
30065    .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
30066    .locate-row{display:flex;gap:8px;align-items:stretch;}
30067    .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;}
30068    .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
30069    body.dark-theme .locate-input{background:var(--surface-2);}
30070    .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;}
30071    .warning-banner.show{display:flex;}
30072    .warning-banner svg{flex:0 0 auto;}
30073    body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
30074    .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;}
30075    .error-inline.show{display:flex;}
30076    .error-inline svg{flex:0 0 auto;margin-top:2px;}
30077    body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
30078    .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
30079    .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
30080    .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
30081    .err-kv-p{margin:0 0 4px;}
30082    .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;}
30083    .success-inline.show{display:flex;}
30084    body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
30085    .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
30086    .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;}
30087    body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
30088    .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
30089    .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
30090    .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
30091    body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
30092    .fh-row:last-child{border-bottom:none;}
30093    .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
30094    .fh-dir{font-weight:800;color:var(--text);}
30095    .fh-hl{color:var(--oxide);font-weight:700;}
30096    .fh-muted{color:var(--muted);}
30097    .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;}
30098    body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
30099    .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
30100    .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
30101    .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
30102    .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;}
30103    .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
30104    .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;}
30105    .btn-secondary:hover{background:var(--line);}
30106    .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;}
30107    .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;}
30108    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
30109    @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));}}
30110    .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;}
30111    .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;}
30112    .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
30113  </style>
30114</head>
30115<body>
30116  <div class="background-watermarks" aria-hidden="true">
30117    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30118    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30119    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30120    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30121    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30122    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30123  </div>
30124  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30125  <div class="top-nav">
30126    <div class="top-nav-inner">
30127      <a class="brand" href="/">
30128        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
30129        <div class="brand-copy">
30130          <div class="brand-title">OxideSLOC</div>
30131          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
30132        </div>
30133      </a>
30134      <div class="nav-right">
30135        <a class="nav-pill" href="/">Home</a>
30136        <div class="nav-dropdown">
30137          <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>
30138          <div class="nav-dropdown-menu">
30139            <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>
30140          </div>
30141        </div>
30142        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30143        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30144        <div class="nav-dropdown">
30145          <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>
30146          <div class="nav-dropdown-menu">
30147            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
30148            <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>
30149          </div>
30150        </div>
30151        <div class="server-status-wrap" id="server-status-wrap">
30152          <div class="nav-pill server-online-pill" id="server-status-pill">
30153            <span class="status-dot" id="status-dot"></span>
30154            <span id="server-status-label">Server</span>
30155            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
30156          </div>
30157          <div class="server-status-tip">
30158            OxideSLOC is running &mdash; accessible on your network.
30159            <span class="sx-238af6bc" id="server-tip-ping" ></span>
30160          </div>
30161        </div>
30162        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30163          <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>
30164        </button>
30165        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30166          <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>
30167          <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>
30168        </button>
30169      </div>
30170    </div>
30171  </div>
30172
30173  <div class="page">
30174    <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
30175    <div class="panel">
30176      <h1>Report File Not Found</h1>
30177      <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>
30178      <div class="field-label">Missing file</div>
30179      <div class="filename-chip">
30180        <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>
30181        {{ expected_filename }}
30182      </div>
30183      <div class="locate-section">
30184        <h2>Locate Scan Output Folder</h2>
30185        <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>
30186        <p>OxideSLOC will find the correct files inside automatically.</p>
30187        <div class="locate-row">
30188          <input type="text" id="locate-file-input"
30189                 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
30190                 class="locate-input" autocomplete="off" spellcheck="false">
30191          {% if !server_mode %}
30192          <button type="button" id="browse-locate-btn" class="btn-secondary">Browse&hellip;</button>
30193          {% endif %}
30194        </div>
30195        <div class="warning-banner" id="filename-warning">
30196          <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>
30197          <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>
30198        </div>
30199        <div class="error-inline" id="locate-error">
30200          <svg class="sx-2b752633" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
30201          <span id="locate-error-text"></span>
30202        </div>
30203        <div class="success-inline" id="locate-success">
30204          <svg class="sx-5becf0bb" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ><polyline points="20 6 9 17 4 12"/></svg>
30205          <span>Scan restored &mdash; loading report&hellip;</span>
30206        </div>
30207        <div class="btn-row">
30208          <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
30209          <a class="btn-secondary" href="/view-reports">View Reports</a>
30210        </div>
30211        <div class="folder-hint-shell">
30212          <div class="folder-hint-hdr">
30213            <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>
30214            Expected Folder Structure &mdash; Select the Top-Level Folder
30215          </div>
30216          <div class="folder-hint-body">
30217            <div class="fh-row">
30218              <span class="fh-tog">&#9658;</span>
30219              <span class="fh-dir">project_20260601-0029-&hellip;/</span>
30220              <span class="fh-badge">&larr; select this</span>
30221            </div>
30222            <div class="fh-row fh-i1">
30223              <span class="fh-tog">&#9658;</span>
30224              <span class="fh-dir">html/</span>
30225            </div>
30226            <div class="fh-row fh-i2">
30227              <span class="fh-bul">&#8226;</span>
30228              <span class="fh-hl">{{ expected_filename }}</span>
30229            </div>
30230            <div class="fh-row fh-i1">
30231              <span class="fh-tog">&#9658;</span>
30232              <span class="fh-dir">json/</span>
30233            </div>
30234            <div class="fh-row fh-i2">
30235              <span class="fh-bul">&#8226;</span>
30236              <span class="fh-muted">result_*.json</span>
30237            </div>
30238            <div class="fh-row fh-i1">
30239              <span class="fh-tog">&#9658;</span>
30240              <span class="fh-dir">pdf/</span>
30241            </div>
30242            <div class="fh-row fh-i2">
30243              <span class="fh-bul">&#8226;</span>
30244              <span class="fh-muted">report_*.pdf</span>
30245            </div>
30246            <div class="fh-row fh-i1">
30247              <span class="fh-tog">&#9658;</span>
30248              <span class="fh-dir">excel/</span>
30249            </div>
30250            <div class="fh-row fh-i2">
30251              <span class="fh-bul">&#8226;</span>
30252              <span class="fh-muted">report_*.csv &nbsp; report_*.xlsx</span>
30253            </div>
30254          </div>
30255        </div>
30256      </div>
30257    </div>
30258  </div>
30259  <footer class="site-footer">
30260    oxide-sloc v{{ version }} &mdash; local code metrics workbench &nbsp;&middot;&nbsp;
30261    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30262    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30263    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30264    &nbsp;&middot;&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
30265  </footer>
30266  <script nonce="{{ csp_nonce }}">(function(){
30267    var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
30268    if(s==="dark")b.classList.add("dark-theme");
30269    document.getElementById("theme-toggle").addEventListener("click",function(){
30270      var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
30271    });
30272  })();</script>
30273  <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
30274    var c=document.getElementById('code-particles');if(!c)return;
30275    var snips = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
30276    for(var i=0;i<44;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));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.108 + 0.072).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);}
30277  })();
30278  (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>
30279  <script nonce="{{ csp_nonce }}">(function(){
30280    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'}];
30281    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);});}
30282    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30283    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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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');});}
30284    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30285  }());</script>
30286  <script nonce="{{ csp_nonce }}">(function(){
30287    var meta=document.getElementById('locate-meta');
30288    var inp=document.getElementById('locate-file-input');
30289    var browseBtn=document.getElementById('browse-locate-btn');
30290    var submitBtn=document.getElementById('locate-submit-btn');
30291    var warning=document.getElementById('filename-warning');
30292    var errBox=document.getElementById('locate-error');
30293    var errText=document.getElementById('locate-error-text');
30294    var okBox=document.getElementById('locate-success');
30295    var expected=meta?meta.getAttribute('data-expected'):'';
30296    var runId=meta?meta.getAttribute('data-run-id'):'';
30297    var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
30298    function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
30299    function showErr(msg){
30300      if(errText){
30301        errText.innerHTML='';
30302        var lines=msg.split('\n');
30303        var hasPairs=lines.some(function(l){return / : /.test(l);});
30304        if(!hasPairs){errText.textContent=msg;}
30305        else{
30306          var frag=document.createDocumentFragment();var tbl=null;
30307          lines.forEach(function(line){
30308            var m=line.match(/^(.*?) : (.*)$/);
30309            if(m){
30310              if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
30311              var tr=document.createElement('tr');
30312              var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
30313              var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
30314              tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
30315            } else {
30316              tbl=null;
30317              if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
30318            }
30319          });
30320          errText.appendChild(frag);
30321        }
30322      }
30323      if(errBox)errBox.classList.add('show');
30324      if(okBox)okBox.classList.remove('show');
30325    }
30326    function clearErr(){
30327      if(errBox)errBox.classList.remove('show');
30328      if(okBox)okBox.classList.remove('show');
30329    }
30330    function validate(){
30331      var val=inp?inp.value.trim():'';
30332      clearErr();
30333      if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
30334      if(submitBtn)submitBtn.disabled=false;
30335      if(warning){
30336        var name=basename(val);
30337        var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
30338        if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
30339        else warning.classList.remove('show');
30340      }
30341    }
30342    if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
30343    if(browseBtn){
30344      browseBtn.addEventListener('click',function(){
30345        browseBtn.disabled=true;browseBtn.textContent='...';
30346        fetch('/pick-directory')
30347          .then(function(r){return r.ok?r.json():{cancelled:true};})
30348          .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
30349          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
30350      });
30351    }
30352    if(submitBtn){
30353      submitBtn.addEventListener('click',function(){
30354        var folder=inp?inp.value.trim():'';
30355        if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
30356        clearErr();
30357        submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
30358        var body=new URLSearchParams();
30359        body.set('file_path',folder);
30360        body.set('redirect_url',redirectUrl);
30361        body.set('expected_run_id',runId);
30362        fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
30363          .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
30364          .then(function(d){
30365            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
30366            if(d&&d.ok){
30367              if(okBox)okBox.classList.add('show');
30368              setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
30369            } else {
30370              showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
30371            }
30372          })
30373          .catch(function(e){
30374            submitBtn.disabled=false;submitBtn.textContent='Restore Report';
30375            showErr('Network error: '+String(e));
30376          });
30377      });
30378    }
30379  })();</script>
30380  <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>
30381</body>
30382</html>
30383"##,
30384    ext = "html"
30385)]
30386struct LocateFileTemplate {
30387    run_id: String,
30388    artifact_type: String,
30389    expected_filename: String,
30390    server_mode: bool,
30391    csp_nonce: String,
30392    version: &'static str,
30393}
30394
30395// ── RelocateScanTemplate ──────────────────────────────────────────────────────
30396
30397#[derive(Template)]
30398#[template(
30399    source = r##"
30400<!doctype html>
30401<html lang="en">
30402<head>
30403  <meta charset="utf-8">
30404  <meta name="viewport" content="width=device-width, initial-scale=1">
30405  <title>OxideSLOC | Locate Scan Files</title>
30406  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30407  <link rel="stylesheet" href="/static/app.css">
30408  <script src="/static/app.js"></script>
30409  <style nonce="{{ csp_nonce }}">
30410    :root {
30411      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30412      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30413      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
30414      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30415    }
30416    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
30417    *{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;}
30418    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30419    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30420    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
30421    .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);}
30422    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
30423    .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));}
30424    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
30425    .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;}
30426    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
30427    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
30428    @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;}}
30429    .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;}
30430    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
30431    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
30432    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
30433    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30434    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30435    .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;}
30436    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30437    .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);}
30438    .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;}
30439    .settings-close:hover{color:var(--text);background:var(--surface-2);}
30440    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
30441    .settings-modal-body{padding:14px 16px 16px;}
30442    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30443    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30444    .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;}
30445    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30446    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30447    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30448    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30449    .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;}
30450    .tz-select:focus{border-color:var(--oxide);}
30451    .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
30452    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
30453    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
30454    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
30455    .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;}
30456    .error-box.hidden{display:none;}
30457    .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;}
30458    body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
30459    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
30460    .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;}
30461    .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;}
30462    .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
30463    .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;}
30464    .btn-secondary:hover{background:var(--line);}
30465    .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;}
30466    .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;}
30467    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
30468    @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));}}
30469    .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;}
30470    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
30471    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
30472    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
30473    .relocate-row{display:flex;gap:8px;align-items:stretch;}
30474    .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;}
30475    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
30476    body.dark-theme .relocate-input{background:var(--surface-2);}
30477  </style>
30478</head>
30479<body>
30480  <div class="background-watermarks" aria-hidden="true">
30481    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30482    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30483    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30484    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30485    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30486    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30487  </div>
30488  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30489  <div class="top-nav">
30490    <div class="top-nav-inner">
30491      <a class="brand" href="/">
30492        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
30493        <div class="brand-copy">
30494          <div class="brand-title">OxideSLOC</div>
30495          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
30496        </div>
30497      </a>
30498      <div class="nav-right">
30499        <a class="nav-pill" href="/">Home</a>
30500        <div class="nav-dropdown">
30501          <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>
30502          <div class="nav-dropdown-menu">
30503            <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>
30504          </div>
30505        </div>
30506        <a class="nav-pill sx-8c38ef73"  href="/compare-scans">Compare Scans</a>
30507        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30508        <div class="nav-dropdown">
30509          <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>
30510          <div class="nav-dropdown-menu">
30511            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
30512            <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>
30513          </div>
30514        </div>
30515        <div class="server-status-wrap" id="server-status-wrap">
30516          <div class="nav-pill server-online-pill" id="server-status-pill">
30517            <span class="status-dot" id="status-dot"></span>
30518            <span id="server-status-label">Server</span>
30519            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
30520          </div>
30521          <div class="server-status-tip">
30522            OxideSLOC is running — accessible on your network.
30523            <span class="sx-238af6bc" id="server-tip-ping" ></span>
30524          </div>
30525        </div>
30526        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30527          <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>
30528        </button>
30529        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30530          <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>
30531          <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>
30532        </button>
30533      </div>
30534    </div>
30535  </div>
30536
30537  <div class="page">
30538    <div class="panel">
30539      <h1>Scan Files Moved</h1>
30540      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
30541      <div class="error-box" id="relocate-error-box">{{ message }}</div>
30542      <div class="success-box" id="relocate-success-box">Scan restored — redirecting&hellip;</div>
30543      <div class="relocate-section">
30544        <h2>Locate Scan Output</h2>
30545        <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>
30546        <div class="relocate-row">
30547          <input type="text" id="relocate-folder" name="folder_path"
30548                 value="{{ folder_hint }}"
30549                 placeholder="Path to folder containing scan output..."
30550                 class="relocate-input" autocomplete="off" spellcheck="false">
30551          {% if !server_mode %}
30552          <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
30553          {% endif %}
30554        </div>
30555        <div class="sx-b6d781cb" >
30556          <button type="button" id="restore-btn" class="btn-primary sx-87798f9f" >Restore Scan</button>
30557        </div>
30558      </div>
30559      <div class="actions">
30560        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
30561        <a class="btn-secondary" href="/view-reports">View Reports</a>
30562      </div>
30563    </div>
30564  </div>
30565  <footer class="site-footer">
30566    oxide-sloc v{{ version }} — local code metrics workbench &nbsp;&middot;&nbsp;
30567    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30568    &nbsp;&middot;&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30569    &nbsp;&middot;&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30570    &nbsp;&middot;&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
30571  </footer>
30572  <script nonce="{{ csp_nonce }}">
30573    (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");});})();
30574    (function spawnCodeParticles(){var c=document.getElementById('code-particles');if(!c)return;var snips = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];for(var i=0;i<44;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));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.108 + 0.072).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);}})();
30575    (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;});})();
30576  </script>
30577  <script nonce="{{ csp_nonce }}">
30578  (function(){
30579    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'}];
30580    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);});}
30581    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30582    function init(){
30583      var btn=document.getElementById('settings-btn');if(!btn)return;
30584      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30585      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
30586      document.body.appendChild(m);
30587      var g=document.getElementById('scheme-grid');
30588      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);});
30589      var cl=document.getElementById('settings-close');
30590      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);});})();
30591      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');});
30592      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30593      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30594    }
30595    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30596  }());
30597  (function(){
30598    var browseBtn=document.getElementById('browse-relocate-btn');
30599    if(browseBtn){
30600      browseBtn.addEventListener('click',function(){
30601        browseBtn.disabled=true;browseBtn.textContent='...';
30602        var inp=document.getElementById('relocate-folder');
30603        var hint=inp?inp.value:'';
30604        fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
30605          .then(function(r){return r.ok?r.json():{cancelled:true};})
30606          .then(function(d){
30607            browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
30608            if(d&&d.selected_path&&inp)inp.value=d.selected_path;
30609          })
30610          .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
30611      });
30612    }
30613    var restoreBtn=document.getElementById('restore-btn');
30614    var errBox=document.getElementById('relocate-error-box');
30615    var okBox=document.getElementById('relocate-success-box');
30616    if(restoreBtn){
30617      restoreBtn.addEventListener('click',function(){
30618        var inp=document.getElementById('relocate-folder');
30619        var folder=inp?inp.value.trim():'';
30620        if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
30621        restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
30622        var body=new URLSearchParams();
30623        body.set('run_id','{{ run_id }}');
30624        body.set('redirect_url','{{ redirect_url }}');
30625        body.set('folder_path',folder);
30626        fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
30627          .then(function(r){return r.json();})
30628          .then(function(d){
30629            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
30630            if(d&&d.ok){
30631              if(errBox)errBox.classList.add('hidden');
30632              if(okBox){okBox.style.display='block';}
30633              setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
30634            } else {
30635              if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
30636            }
30637          })
30638          .catch(function(e){
30639            restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
30640            if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
30641          });
30642      });
30643    }
30644  }());
30645  </script>
30646  <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]';
30647  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;}
30648  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>
30649</body>
30650</html>
30651"##,
30652    ext = "html"
30653)]
30654struct RelocateScanTemplate {
30655    message: String,
30656    run_id: String,
30657    folder_hint: String,
30658    redirect_url: String,
30659    server_mode: bool,
30660    csp_nonce: String,
30661    version: &'static str,
30662}
30663
30664// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
30665
30666#[derive(Template)]
30667#[template(
30668    source = r##"
30669<!doctype html>
30670<html lang="en">
30671<head>
30672  <meta charset="utf-8">
30673  <meta name="viewport" content="width=device-width, initial-scale=1">
30674  <title>OxideSLOC | View Reports</title>
30675  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30676  <link rel="stylesheet" href="/static/app.css">
30677  <script src="/static/app.js"></script>
30678  <style nonce="{{ csp_nonce }}">
30679    :root {
30680      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
30681      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30682      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30683      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30684      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
30685    }
30686    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; }
30687    *{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;}
30688    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30689    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30690    .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);}
30691    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
30692    .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));}
30693    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
30694    .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;}
30695    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
30696    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30697    @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; } }
30698    .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;}
30699    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
30700    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
30701    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
30702    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30703    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30704    .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;}
30705    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30706    .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);}
30707    .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;}
30708    .settings-close:hover{color:var(--text);background:var(--surface-2);}
30709    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
30710    .settings-modal-body{padding:14px 16px 16px;}
30711    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30712    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30713    .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;}
30714    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30715    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30716    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30717    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30718    .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;}
30719    .tz-select:focus{border-color:var(--oxide);}
30720    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
30721    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
30722    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
30723    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
30724    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30725    .panel-meta{font-size:13px;color:var(--muted);}
30726    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
30727    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
30728    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
30729    .per-page-label{font-size:13px;color:var(--muted);}
30730    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;}
30731    .filter-input{min-width:180px;cursor:text;}
30732    .table-wrap{width:100%;overflow-x:auto;}
30733    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
30734    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;}
30735    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
30736    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
30737    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
30738    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
30739    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
30740    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
30741    tr:last-child td{border-bottom:none;}
30742    tr:hover td{background:var(--surface-2);}
30743    .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);}
30744    .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);}
30745    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
30746    .metric-num{font-weight:700;color:var(--text);}
30747    .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
30748    .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
30749    .git-commit-chip{cursor:help;}
30750    .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;}
30751    .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;}
30752    .btn:hover{background:var(--line);}
30753    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
30754    .btn.primary:hover{opacity:.9;}
30755    .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;}
30756    .btn-back:hover{background:var(--line);}
30757    .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;}
30758    .export-btn:hover{background:var(--line);}
30759    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
30760    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
30761    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
30762    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
30763    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
30764    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
30765    .pagination-info{font-size:13px;color:var(--muted);}
30766    .pagination-btns{display:flex;gap:6px;}
30767    .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;}
30768    .pg-btn:hover:not(:disabled){background:var(--line);}
30769    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
30770    .pg-btn:disabled{opacity:.35;cursor:default;}
30771    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
30772    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
30773    .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);}
30774    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
30775    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
30776    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
30777    .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);}
30778    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
30779    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
30780    .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;}
30781    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30782    .site-footer a{color:var(--muted);}
30783    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
30784    .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%;}
30785    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
30786    .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;}
30787    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
30788    .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;}
30789    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
30790    .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;}
30791    .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;}
30792    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
30793    @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));}}
30794    .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;}
30795    .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;}
30796    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
30797    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
30798    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
30799    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
30800    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
30801    .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;}
30802    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
30803    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
30804    .watched-chip-rm:hover{color:var(--oxide);}
30805    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
30806    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
30807    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
30808    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
30809    .rpt-btn{min-width:58px;justify-content:center;}
30810    .flex-row{display:flex;align-items:center;gap:8px;}
30811    .report-cell{overflow:visible;white-space:normal;}
30812    #history-table col:nth-child(1){width:185px;}
30813    #history-table col:nth-child(2){width:220px;}
30814    #history-table col:nth-child(3){width:100px;}
30815    #history-table col:nth-child(4){width:72px;}
30816    #history-table col:nth-child(5){width:82px;}
30817    #history-table col:nth-child(6){width:82px;}
30818    #history-table col:nth-child(7){width:65px;}
30819    #history-table col:nth-child(8){width:90px;}
30820    #history-table col:nth-child(9){width:85px;}
30821    #history-table col:nth-child(10){width:115px;}
30822    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
30823    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
30824    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
30825    .submod-details summary::-webkit-details-marker{display:none;}
30826.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
30827    .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;}
30828    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
30829    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
30830  </style>
30831</head>
30832<body>
30833  <div class="background-watermarks" aria-hidden="true">
30834    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30835    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30836    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30837    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30838    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30839    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30840  </div>
30841  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30842  <div class="top-nav">
30843    <div class="top-nav-inner">
30844      <a class="brand" href="/">
30845        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30846        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
30847      </a>
30848      <div class="nav-right">
30849        <a class="nav-pill" href="/">Home</a>
30850        <div class="nav-dropdown">
30851          <a href="/view-reports" class="nav-dropdown-btn sx-8c38ef73" >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>
30852          <div class="nav-dropdown-menu">
30853            <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>
30854          </div>
30855        </div>
30856        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30857        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30858        <div class="nav-dropdown">
30859          <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>
30860          <div class="nav-dropdown-menu">
30861            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
30862            <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>
30863          </div>
30864        </div>
30865        <div class="server-status-wrap" id="server-status-wrap">
30866          <div class="nav-pill server-online-pill" id="server-status-pill">
30867            <span class="status-dot" id="status-dot"></span>
30868            <span id="server-status-label">Server</span>
30869            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
30870          </div>
30871          <div class="server-status-tip">
30872            OxideSLOC is running — accessible on your network.
30873            <span class="sx-238af6bc" id="server-tip-ping" ></span>
30874          </div>
30875        </div>
30876        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30877          <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>
30878        </button>
30879        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30880          <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>
30881          <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>
30882        </button>
30883      </div>
30884    </div>
30885  </div>
30886
30887  <div class="page">
30888    {% if let Some(err) = browse_error %}
30889    <div class="toast-error">
30890      <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>
30891      {{ err }}
30892    </div>
30893    {% endif %}
30894    {% if linked_count > 0 %}
30895    <div class="toast-success">
30896      <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>
30897      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
30898    </div>
30899    {% endif %}
30900    <div class="watched-bar">
30901      <div class="watched-bar-left">
30902        <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>
30903        <span class="watched-label">Watched Folders</span>
30904        <div class="watched-chips">
30905          {% if server_mode %}
30906          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
30907          {% else %}
30908          {% for dir in watched_dirs %}
30909          <span class="watched-chip">
30910            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
30911            <form class="sx-043808a9" method="POST" action="/watched-dirs/remove" >
30912              <input type="hidden" name="folder_path" value="{{ dir }}">
30913              <input type="hidden" name="redirect_to" value="/view-reports">
30914              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
30915            </form>
30916          </span>
30917          {% endfor %}
30918          {% if watched_dirs.is_empty() %}
30919          <span class="watched-none">No folders watched — click Choose to add one</span>
30920          {% endif %}
30921          {% endif %}
30922        </div>
30923      </div>
30924      {% if !server_mode %}
30925      <div class="watched-bar-right">
30926        <button type="button" class="btn" id="add-watched-btn">
30927          <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>
30928          Choose
30929        </button>
30930        <form class="sx-043808a9" method="POST" action="/watched-dirs/refresh" >
30931          <input type="hidden" name="redirect_to" value="/view-reports">
30932          <button type="submit" class="btn">&#8635; Refresh</button>
30933        </form>
30934      </div>
30935      {% endif %}
30936    </div>
30937    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
30938      <div class="scan-overlay-card">
30939        <div class="scan-spinner"></div>
30940        <div class="scan-overlay-text">Scanning folder…</div>
30941        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
30942      </div>
30943    </div>
30944    <style nonce="{{ csp_nonce }}">
30945    .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);}
30946    .scan-overlay.active{display:flex;}
30947    .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;}
30948    .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;}
30949    @keyframes scanSpin{to{transform:rotate(360deg);}}
30950    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
30951    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
30952    </style>
30953    {% if total_scans > 0 %}
30954    <div class="summary-strip">
30955      <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>
30956      <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>
30957      <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>
30958      <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>
30959    </div>
30960    {% endif %}
30961
30962    <section class="panel">
30963      <div class="panel-header">
30964        <div>
30965          <h1>View Reports</h1>
30966          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
30967          {% if server_mode %}<p class="panel-meta sx-4db908e4" >Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
30968        </div>
30969        <div class="flex-row">
30970          <button type="button" class="export-btn" id="export-csv-btn">
30971            <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>
30972            Export CSV
30973          </button>
30974          <button type="button" class="export-btn" id="export-xls-btn">
30975            <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>
30976            Export Excel
30977          </button>
30978        </div>
30979      </div>
30980
30981      {% if entries.is_empty() %}
30982      <div class="empty-state">
30983        <strong>No reports with viewable HTML yet</strong>
30984        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.
30985      </div>
30986      {% else %}
30987      <div class="filter-row">
30988        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
30989        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
30990        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
30991      </div>
30992      <div class="table-wrap">
30993        <table id="history-table">
30994          <colgroup>
30995            <col><col><col><col><col><col><col><col><col><col><col>
30996          </colgroup>
30997          <thead>
30998            <tr id="history-thead">
30999              <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>
31000              <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>
31001              <th>Run ID<div class="col-resize-handle"></div></th>
31002              <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>
31003              <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>
31004              <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>
31005              <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>
31006              <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>
31007              <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>
31008              <th class="sortable" data-sort-col="environment" data-sort-type="str">Environment<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>
31009              <th>Report<div class="col-resize-handle"></div></th>
31010            </tr>
31011          </thead>
31012          <tbody id="history-tbody">
31013            {% for entry in entries %}
31014            <tr class="history-row" data-run="{{ entry.run_id }}"
31015                data-timestamp="{{ entry.timestamp }}"
31016                data-project="{{ entry.project_label }}"
31017                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
31018                data-skipped="{{ entry.files_skipped }}"
31019                data-comments="{{ entry.comment_lines }}"
31020                data-blank="{{ entry.blank_lines }}"
31021                data-physical="{{ entry.total_physical_lines }}"
31022                data-functions="{{ entry.functions }}"
31023                data-classes="{{ entry.classes }}"
31024                data-variables="{{ entry.variables }}"
31025                data-imports="{{ entry.imports }}"
31026                data-tests="{{ entry.test_count }}"
31027                data-branch="{{ entry.git_branch }}"
31028                data-commit="{{ entry.git_commit }}"
31029                data-environment="{{ entry.performed_by }}"
31030                data-has-json="{{ entry.has_json }}"
31031                data-html-url="/runs/html/{{ entry.run_id }}">
31032              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
31033              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
31034              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
31035              <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>
31036              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
31037              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
31038              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
31039              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
31040              <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>
31041              <td>{% if !entry.performed_by.is_empty() %}<span class="git-chip"{% if !entry.scan_os.is_empty() %} title="OS: {{ entry.scan_os }}"{% endif %}>{{ entry.performed_by }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
31042              <td class="report-cell">
31043                <div class="actions-cell">
31044                  {% 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 %}
31045                  {% 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 %}
31046                </div>
31047                {% if !entry.submodule_links.is_empty() %}
31048                <details class="submod-details">
31049                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
31050                  <div class="submod-link-list">
31051                    {% for sub in entry.submodule_links %}
31052                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
31053                    {% endfor %}
31054                  </div>
31055                </details>
31056                {% endif %}
31057              </td>
31058            </tr>
31059            {% endfor %}
31060          </tbody>
31061        </table>
31062      </div>
31063      <div class="pagination">
31064        <span class="pagination-info" id="pagination-info"></span>
31065        <div class="pagination-btns" id="pagination-btns"></div>
31066        <div class="flex-row">
31067          <span class="per-page-label">Show</span>
31068          <select class="per-page" id="per-page-sel">
31069            <option value="10">10 per page</option>
31070            <option value="25" selected>25 per page</option>
31071            <option value="50">50 per page</option>
31072            <option value="100">100 per page</option>
31073          </select>
31074          <span class="per-page-label" id="page-range-label"></span>
31075        </div>
31076      </div>
31077      {% endif %}
31078    </section>
31079  </div>
31080
31081  <footer class="site-footer">
31082    local code analysis - metrics, history and reports
31083    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
31084    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31085    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31086    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31087    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
31088  </footer>
31089
31090  <script nonce="{{ csp_nonce }}">
31091    (function () {
31092      // ── Theme ──────────────────────────────────────────────────────────────
31093      var storageKey = 'oxide-sloc-theme';
31094      var body = document.body;
31095      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
31096      var toggle = document.getElementById('theme-toggle');
31097      if (toggle) toggle.addEventListener('click', function () {
31098        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
31099        body.classList.toggle('dark-theme', next === 'dark');
31100        try { localStorage.setItem(storageKey, next); } catch(e) {}
31101      });
31102
31103      // ── State ─────────────────────────────────────────────────────────────
31104      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
31105      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
31106      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
31107
31108      // Aggregate stats from first (most recent) row
31109      if (allRows.length) {
31110        var first = allRows[0];
31111        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();}
31112        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>':'');}
31113        setChipVal('agg-code', first.dataset.code);
31114        setChipVal('agg-files', first.dataset.files);
31115        var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
31116        var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
31117        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(); });
31118      }
31119
31120      // ── Branch filter population ──────────────────────────────────────────
31121      (function() {
31122        var branches = {};
31123        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
31124        var sel = document.getElementById('branch-filter');
31125        if (sel) Object.keys(branches).sort().forEach(function(b) {
31126          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
31127        });
31128      })();
31129
31130      // ── Filter ────────────────────────────────────────────────────────────
31131      function getFilteredRows() {
31132        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
31133        var branch = ((document.getElementById('branch-filter') || {}).value || '');
31134        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
31135          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
31136          if (branch && (r.dataset.branch || '') !== branch) return false;
31137          return true;
31138        });
31139      }
31140
31141      // ── Pagination ────────────────────────────────────────────────────────
31142      function renderPage() {
31143        var filtered = getFilteredRows();
31144        var total = filtered.length;
31145        var totalPages = Math.max(1, Math.ceil(total / perPage));
31146        currentPage = Math.min(currentPage, totalPages);
31147        var start = (currentPage - 1) * perPage;
31148        var end = Math.min(start + perPage, total);
31149        var shown = {};
31150        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
31151        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
31152          r.style.display = shown[r.dataset.run] ? '' : 'none';
31153        });
31154        var rl = document.getElementById('page-range-label');
31155        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
31156        var info = document.getElementById('pagination-info');
31157        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
31158        var btns = document.getElementById('pagination-btns');
31159        if (!btns) return;
31160        btns.innerHTML = '';
31161        function makeBtn(lbl, pg, active, disabled) {
31162          var b = document.createElement('button');
31163          b.className = 'pg-btn' + (active ? ' active' : '');
31164          b.textContent = lbl; b.disabled = disabled;
31165          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
31166          return b;
31167        }
31168        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
31169        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
31170        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
31171        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
31172      }
31173
31174      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
31175      window.applyFilters = function() { currentPage = 1; renderPage(); };
31176
31177      // ── Sorting ───────────────────────────────────────────────────────────
31178      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
31179      function doSort(col, type, order) {
31180        var tbody = document.getElementById('history-tbody');
31181        if (!tbody) return;
31182        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
31183        rows.sort(function(a, b) {
31184          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
31185          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
31186          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
31187          return va < vb ? 1 : va > vb ? -1 : 0;
31188        });
31189        rows.forEach(function(r) { tbody.appendChild(r); });
31190        currentPage = 1; renderPage();
31191      }
31192      sortHeaders.forEach(function(th) {
31193        th.addEventListener('click', function(e) {
31194          if (e.target.classList.contains('col-resize-handle')) return;
31195          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
31196          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
31197          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
31198          th.classList.add('sort-' + sortOrder);
31199          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
31200          doSort(col, type, sortOrder);
31201        });
31202      });
31203
31204      // ── Column resize ─────────────────────────────────────────────────────
31205      (function() {
31206        var table = document.getElementById('history-table');
31207        if (!table) return;
31208        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
31209        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
31210        ths.forEach(function(th, i) {
31211          var handle = th.querySelector('.col-resize-handle');
31212          if (!handle || !cols[i]) return;
31213          var startX, startW;
31214          handle.addEventListener('mousedown', function(e) {
31215            e.stopPropagation(); e.preventDefault();
31216            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
31217            handle.classList.add('dragging');
31218            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
31219            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
31220            document.addEventListener('mousemove', onMove);
31221            document.addEventListener('mouseup', onUp);
31222          });
31223        });
31224      })();
31225
31226      // ── Full-commit hover tooltip ─────────────────────────────────────────
31227      // The commit chips live inside an overflow:auto table wrapper, which would
31228      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
31229      // (escaping the scroll container) and follow the cursor. Event delegation
31230      // keeps it working after pagination/sorting re-renders the rows.
31231      (function() {
31232        var tip = document.createElement('div');
31233        tip.className = 'commit-tip';
31234        tip.setAttribute('role', 'tooltip');
31235        document.body.appendChild(tip);
31236        var shown = false;
31237        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
31238        function place(e) {
31239          var pad = 14, r = tip.getBoundingClientRect();
31240          var x = e.clientX + pad, y = e.clientY + pad;
31241          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
31242          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
31243          tip.style.left = x + 'px'; tip.style.top = y + 'px';
31244        }
31245        function hide() { tip.style.display = 'none'; shown = false; }
31246        document.addEventListener('mouseover', function(e) {
31247          var chip = chipFrom(e.target);
31248          if (!chip) return;
31249          var full = chip.getAttribute('data-full-commit');
31250          if (!full) return;
31251          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
31252        });
31253        document.addEventListener('mousemove', function(e) {
31254          if (!shown) return;
31255          if (chipFrom(e.target)) place(e); else hide();
31256        });
31257        document.addEventListener('mouseout', function(e) {
31258          if (chipFrom(e.target)) hide();
31259        });
31260      })();
31261
31262      // ── Reset view ────────────────────────────────────────────────────────
31263      window.resetView = function() {
31264        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
31265        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
31266        sortCol = null; sortOrder = 'asc';
31267        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
31268        var tbody = document.getElementById('history-tbody');
31269        if (tbody) {
31270          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
31271          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
31272          rows.forEach(function(r) { tbody.appendChild(r); });
31273        }
31274        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
31275        var table = document.getElementById('history-table');
31276        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
31277        currentPage = 1; renderPage();
31278      };
31279
31280      renderPage();
31281
31282      // ── Export helpers ────────────────────────────────────────────────────
31283      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
31284      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
31285      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);}
31286      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;');}
31287      function slocXlsx(fname,sheet,hdrs,rows){
31288        var enc=new TextEncoder();
31289        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;}
31290        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;}
31291        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
31292        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
31293        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
31294        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;}
31295        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
31296        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];}
31297        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
31298        // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
31299        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
31300          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
31301          +'<fonts count="2">'
31302            +'<font><sz val="11"/><name val="Calibri"/></font>'
31303            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
31304          +'</fonts>'
31305          +'<fills count="3">'
31306            +'<fill><patternFill patternType="none"/></fill>'
31307            +'<fill><patternFill patternType="gray125"/></fill>'
31308            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
31309          +'</fills>'
31310          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
31311          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
31312          +'<cellXfs count="4">'
31313            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
31314            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
31315            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
31316            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
31317          +'</cellXfs>'
31318          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
31319          +'</styleSheet>';
31320        var rx='<row r="1">';
31321        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
31322        rx+='</row>';
31323        rows.forEach(function(row,ri){
31324          var rn=ri+2;rx+='<row r="'+rn+'">';
31325          row.forEach(function(cell,c){
31326            var ref=colRef(c,rn),sv=String(cell==null?'':cell);
31327            var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
31328            var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
31329            if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
31330            else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
31331            else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
31332          });
31333          rx+='</row>';
31334        });
31335        var lastCol=hdrs.length,lastRow=rows.length+1;
31336        var tableRef='A1:'+colNm(lastCol)+lastRow;
31337        var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31338          +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
31339          +'<autoFilter ref="'+tableRef+'"/>'
31340          +'<tableColumns count="'+lastCol+'">'
31341          +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
31342          +'</tableColumns>'
31343          +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
31344          +'</table>';
31345        var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31346          +'<Relationships xmlns="'+pns+'relationships">'
31347          +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
31348          +'</Relationships>';
31349        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>';
31350        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
31351          +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
31352          +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
31353          +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
31354          +'</worksheet>';
31355        var F={
31356          '[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>',
31357          '_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>',
31358          '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>',
31359          '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>',
31360          'xl/styles.xml':stl,
31361          'xl/sharedStrings.xml':ssXml,
31362          'xl/worksheets/sheet1.xml':sh,
31363          'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
31364          'xl/tables/table1.xml':tableXml
31365        };
31366        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'];
31367        var zparts=[],zcds=[],zoff=0,znf=0;
31368        order.forEach(function(name){
31369          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
31370          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]);
31371          var entry=new Uint8Array(lha.length+nb.length+sz);
31372          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
31373          zparts.push(entry);
31374          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));
31375          var cde=new Uint8Array(cda.length+nb.length);
31376          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
31377          zcds.push(cde);zoff+=entry.length;znf++;
31378        });
31379        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
31380        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]);
31381        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
31382        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
31383        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
31384        zout.set(new Uint8Array(ea),zpos);
31385        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
31386      }
31387
31388      // Multi-sheet XLSX builder for the scan-history export.
31389      // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
31390      function slocXlsxMulti(fname,sheets){
31391        var enc=new TextEncoder();
31392        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;}
31393        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;}
31394        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
31395        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
31396        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
31397        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];}
31398        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;}
31399        function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
31400        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
31401        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
31402          +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
31403          +'<fonts count="3">'
31404            +'<font><sz val="11"/><name val="Calibri"/></font>'
31405            +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
31406            +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
31407          +'</fonts>'
31408          +'<fills count="4">'
31409            +'<fill><patternFill patternType="none"/></fill>'
31410            +'<fill><patternFill patternType="gray125"/></fill>'
31411            +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
31412            +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
31413          +'</fills>'
31414          +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
31415          +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
31416          +'<cellXfs count="7">'
31417            +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
31418            +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
31419            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
31420            +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
31421            +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
31422            +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
31423            +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
31424          +'</cellXfs>'
31425          +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
31426          +'</styleSheet>';
31427        var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
31428        sheets.forEach(function(sh,sheetIdx){
31429          var rx='<row r="1">';
31430          sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
31431          rx+='</row>';
31432          var rn=2;
31433          sh.rows.forEach(function(row){
31434            if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
31435            if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
31436              rx+='<row r="'+rn+'">';
31437              rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
31438              for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
31439              rx+='</row>';rn++;return;
31440            }
31441            rx+='<row r="'+rn+'">';
31442            row.forEach(function(cell,c){
31443              var ref=colRef(c,rn);
31444              if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
31445              if(typeof cell==='object'&&cell!==null){
31446                var cv=cell.v,cs=cell.s!=null?cell.s:0;
31447                if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
31448                else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
31449                return;
31450              }
31451              if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
31452              rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
31453            });
31454            rx+='</row>';rn++;
31455          });
31456          var cw='';
31457          if(sh.colWidths&&sh.colWidths.length>0){
31458            cw='<cols>';
31459            sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
31460            cw+='</cols>';
31461          }
31462          var tblParts='';
31463          if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
31464            tableCounter++;
31465            var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
31466            var tRef='A1:'+colNm(colCount)+rowCount;
31467            tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31468              +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
31469              +'<autoFilter ref="'+tRef+'"/>'
31470              +'<tableColumns count="'+colCount+'">'
31471              +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
31472              +'</tableColumns>'
31473              +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
31474              +'</table>';
31475            wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31476              +'<Relationships xmlns="'+pns+'relationships">'
31477              +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
31478              +'</Relationships>';
31479            tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
31480          }
31481          wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
31482            +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
31483            +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
31484        });
31485        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>';
31486        var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
31487        var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
31488        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>';
31489        var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
31490        var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
31491        var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
31492        wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
31493          +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
31494        var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
31495        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};
31496        var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
31497        sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
31498        Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
31499        Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
31500        var zparts=[],zcds=[],zoff=0,znf=0;
31501        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++;});
31502        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
31503        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]);
31504        var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
31505        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
31506        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
31507        zout.set(new Uint8Array(ea),zpos);
31508        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
31509      }
31510
31511      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'};
31512      function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
31513
31514      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','Environment'];
31515      function getHistoryRows(){
31516        var r=[];
31517        document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
31518          var code=Number(tr.getAttribute('data-code'))||0;
31519          var phys=Number(tr.getAttribute('data-physical'))||0;
31520          var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
31521          r.push([
31522            tr.getAttribute('data-timestamp')||'',
31523            tr.getAttribute('data-project')||'',
31524            tr.getAttribute('data-run')||'',
31525            tr.getAttribute('data-physical')||'',
31526            tr.getAttribute('data-code')||'',
31527            tr.getAttribute('data-comments')||'',
31528            tr.getAttribute('data-blank')||'',
31529            tr.getAttribute('data-files')||'',
31530            tr.getAttribute('data-skipped')||'',
31531            tr.getAttribute('data-functions')||'',
31532            tr.getAttribute('data-classes')||'',
31533            tr.getAttribute('data-variables')||'',
31534            tr.getAttribute('data-imports')||'',
31535            tr.getAttribute('data-tests')||'',
31536            dens,
31537            tr.getAttribute('data-branch')||'',
31538            tr.getAttribute('data-commit')||'',
31539            tr.getAttribute('data-environment')||''
31540          ]);
31541        });
31542        return r;
31543      }
31544      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
31545      window.exportHistoryXls = function(){
31546        var histRows=getHistoryRows();
31547        function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
31548        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],r[17]];});
31549        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,20]};
31550        var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
31551        if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
31552        var runId=jsonRow.getAttribute('data-run')||'';
31553        var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
31554        function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
31555        fetch('/runs/json/'+runId)
31556          .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
31557          .then(function(run){
31558            var tot=run.summary_totals||{};
31559            var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
31560            var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
31561            function B(v){return{v:v,s:4};}
31562            function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
31563            var sumRows=[
31564              [{_sec:true,v:'RUN INFORMATION'}],
31565              [B('Run ID'),(run.tool&&run.tool.run_id)||''],
31566              [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
31567              [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
31568              [B('Branch'),run.git_branch||''],
31569              [B('Commit'),run.git_commit_long||run.git_commit_short||''],
31570              [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
31571              [B('Files Analyzed'),N(tot.files_analyzed)],
31572              [B('Files Skipped'),N(tot.files_skipped)],
31573              [],
31574              [{_sec:true,v:'CODE METRICS'}],
31575              [B('Physical Lines'),N(phys)],
31576              [B('Code Lines'),N(code)],
31577              [B('Comments'),N(tot.comment_lines)],
31578              [B('Blank Lines'),N(tot.blank_lines)],
31579              [B('Mixed Separate'),N(tot.mixed_lines_separate)],
31580              [B('Functions'),N(tot.functions)],
31581              [B('Classes / Types'),N(tot.classes)],
31582              [B('Variables'),N(tot.variables)],
31583              [B('Imports'),N(tot.imports)],
31584              [B('Tests'),N(tot.test_count)],
31585              [B('Assertions'),N(tot.test_assertion_count)],
31586              [B('Test Suites'),N(tot.test_suite_count)],
31587              [B('Code Density'),{v:dens,s:6}],
31588              [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
31589            ];
31590            var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
31591            var langRows=(run.totals_by_language||[]).map(function(l){
31592              var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
31593              var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
31594              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];
31595            });
31596            var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
31597            var pfRows=(run.per_file_records||[]).map(function(r){
31598              var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
31599              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];
31600            });
31601            var skHdrs=['File','Status','Size (bytes)'];
31602            var skRows=(run.skipped_file_records||[]).map(function(r){
31603              return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
31604            });
31605            slocXlsxMulti('scan-history.xlsx',[
31606              histSheet,
31607              {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
31608              {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
31609              {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
31610              {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
31611            ]);
31612          })
31613          .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
31614      };
31615
31616      var csvBtn = document.getElementById('export-csv-btn');
31617      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
31618      var xlsBtn = document.getElementById('export-xls-btn');
31619      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
31620
31621      // ── Remaining CSP-safe event bindings ────────────────────────────────
31622      (function wireEvents() {
31623        var el;
31624        el = document.getElementById('reset-view-btn');
31625        if (el) el.addEventListener('click', window.resetView);
31626        el = document.getElementById('project-filter');
31627        if (el) el.addEventListener('input', window.applyFilters);
31628        el = document.getElementById('branch-filter');
31629        if (el) el.addEventListener('change', window.applyFilters);
31630        el = document.getElementById('per-page-sel');
31631        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
31632        (function(){
31633          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');};
31634          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);
31635        })();
31636        el = document.getElementById('add-watched-btn');
31637        if (el) el.addEventListener('click', function() {
31638          fetch('/pick-directory?kind=reports')
31639            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
31640            .then(function(data) {
31641              if (!data.cancelled && data.selected_path) {
31642                var form = document.createElement('form');
31643                form.method = 'POST';
31644                form.action = '/watched-dirs/add';
31645                var ri = document.createElement('input');
31646                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
31647                var fi = document.createElement('input');
31648                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
31649                form.appendChild(ri); form.appendChild(fi);
31650                document.body.appendChild(form);
31651                if (window.__scanOverlay) window.__scanOverlay();
31652                form.submit();
31653              }
31654            })
31655            .catch(function(e) { alert('Could not open folder picker: ' + e); });
31656        });
31657      })();
31658
31659      (function randomizeWatermarks() {
31660        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31661        if (!wms.length) return;
31662        var placed = [];
31663        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;}
31664        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];}
31665        var half=Math.floor(wms.length/2);
31666        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;});
31667      })();
31668
31669      (function spawnCodeParticles() {
31670        var container = document.getElementById('code-particles');
31671        if (!container) return;
31672        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
31673        for (var i = 0; i < 44; i++) {
31674          (function(idx) {
31675            var el = document.createElement('span');
31676            el.className = 'code-particle';
31677            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
31678            var left = Math.random() * 94 + 2;
31679            var top = Math.random() * 88 + 6;
31680            var dur = (Math.random() * 10 + 9).toFixed(1);
31681            var delay = (Math.random() * 18).toFixed(1);
31682            var rot = (Math.random() * 26 - 13).toFixed(1);
31683            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
31684            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';
31685            container.appendChild(el);
31686          })(i);
31687        }
31688      })();
31689    })();
31690  </script>
31691  <script nonce="{{ csp_nonce }}">
31692  (function(){
31693    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'}];
31694    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);});}
31695    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31696    function init(){
31697      var btn=document.getElementById('settings-btn');if(!btn)return;
31698      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31699      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
31700      document.body.appendChild(m);
31701      var g=document.getElementById('scheme-grid');
31702      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);});
31703      var cl=document.getElementById('settings-close');
31704      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);});})();
31705      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');});
31706      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31707      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31708    }
31709    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
31710  }());
31711  </script>
31712  <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>
31713</body>
31714</html>
31715"##,
31716    ext = "html"
31717)]
31718struct HistoryTemplate {
31719    version: &'static str,
31720    entries: Vec<HistoryEntryRow>,
31721    total_scans: usize,
31722    linked_count: usize,
31723    browse_error: Option<String>,
31724    watched_dirs: Vec<String>,
31725    csp_nonce: String,
31726    server_mode: bool,
31727}
31728
31729// ── CompareSelectTemplate ──────────────────────────────────────────────────────
31730
31731#[derive(Template)]
31732#[template(
31733    source = r##"
31734<!doctype html>
31735<html lang="en">
31736<head>
31737  <meta charset="utf-8">
31738  <meta name="viewport" content="width=device-width, initial-scale=1">
31739  <title>OxideSLOC | Compare Scans</title>
31740  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31741  <link rel="stylesheet" href="/static/app.css">
31742  <script src="/static/app.js"></script>
31743  <style nonce="{{ csp_nonce }}">
31744    :root {
31745      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
31746      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31747      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31748      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31749      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
31750    }
31751    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
31752    *{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;}
31753    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31754    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31755    .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);}
31756    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
31757    .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));}
31758    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
31759    .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;}
31760    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
31761    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31762    @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; } }
31763    .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;}
31764    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
31765    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
31766    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
31767    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31768    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31769    .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;}
31770    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31771    .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);}
31772    .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;}
31773    .settings-close:hover{color:var(--text);background:var(--surface-2);}
31774    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
31775    .settings-modal-body{padding:14px 16px 16px;}
31776    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31777    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31778    .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;}
31779    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31780    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31781    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31782    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31783    .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;}
31784    .tz-select:focus{border-color:var(--oxide);}
31785    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
31786    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
31787    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
31788    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
31789    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31790    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
31791    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
31792    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
31793    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
31794    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
31795    .per-page-label{font-size:13px;color:var(--muted);}
31796    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;}
31797    .filter-input{min-width:180px;cursor:text;}
31798    .table-wrap{width:100%;overflow-x:auto;}
31799    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
31800    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;}
31801    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
31802    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
31803    #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;}
31804    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
31805    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
31806    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
31807    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
31808    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
31809    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
31810    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
31811    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
31812    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
31813    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
31814    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
31815    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
31816    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
31817    tr:last-child td{border-bottom:none;}
31818    tr.selected td{background:var(--sel-bg);}
31819    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
31820    tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
31821    tr{cursor:pointer;}
31822    tr.row-locked{opacity:.35;cursor:not-allowed;}
31823    tr.row-locked td{pointer-events:none;}
31824    .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;}
31825    .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
31826    .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;}
31827    .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
31828    body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
31829    body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
31830    .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);}
31831    .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);}
31832    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
31833    .metric-num{font-weight:700;color:var(--text);}
31834    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
31835    .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;}
31836    .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;}
31837    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
31838    .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;}
31839    .btn:hover{background:var(--line);}
31840    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
31841    .btn.primary:hover{opacity:.9;}
31842    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
31843    .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;}
31844    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
31845    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
31846    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
31847    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
31848    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
31849    .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;}
31850    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
31851    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
31852    .watched-chip-rm:hover{color:var(--oxide);}
31853    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
31854    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
31855    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
31856    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
31857    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
31858    .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;}
31859    .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;}
31860    .btn-back:hover{background:var(--line);}
31861    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
31862    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
31863    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
31864    .pagination-info{font-size:13px;color:var(--muted);}
31865    .pagination-btns{display:flex;gap:6px;}
31866    .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;}
31867    .pg-btn:hover:not(:disabled){background:var(--line);}
31868    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
31869    .pg-btn:disabled{opacity:.35;cursor:default;}
31870    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
31871    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31872    .site-footer a{color:var(--muted);}
31873    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
31874    .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;}
31875    .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;}
31876    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
31877    @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));}}
31878    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
31879    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
31880    .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);}
31881    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
31882    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
31883    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
31884    .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);}
31885    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
31886    .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
31887    .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;}
31888    .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;}
31889    .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%;}
31890    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
31891    .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;}
31892    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
31893    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
31894    .hidden{display:none!important;}
31895    .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%;}
31896    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
31897    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
31898    .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;}
31899    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
31900    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
31901    .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;}
31902    .scope-option:hover{background:var(--line);}
31903    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
31904    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
31905    .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;}
31906    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
31907    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
31908    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
31909    .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;}
31910  </style>
31911</head>
31912<body>
31913  <div class="background-watermarks" aria-hidden="true">
31914    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31915    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31916    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31917    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31918    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31919    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31920  </div>
31921  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31922  <div class="top-nav">
31923    <div class="top-nav-inner">
31924      <a class="brand" href="/">
31925        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31926        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
31927      </a>
31928      <div class="nav-right">
31929        <a class="nav-pill" href="/">Home</a>
31930        <div class="nav-dropdown">
31931          <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>
31932          <div class="nav-dropdown-menu">
31933            <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>
31934          </div>
31935        </div>
31936        <a class="nav-pill sx-8c38ef73"  href="/compare-scans">Compare Scans</a>
31937        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31938        <div class="nav-dropdown">
31939          <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>
31940          <div class="nav-dropdown-menu">
31941            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
31942            <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>
31943          </div>
31944        </div>
31945        <div class="server-status-wrap" id="server-status-wrap">
31946          <div class="nav-pill server-online-pill" id="server-status-pill">
31947            <span class="status-dot" id="status-dot"></span>
31948            <span id="server-status-label">Server</span>
31949            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
31950          </div>
31951          <div class="server-status-tip">
31952            OxideSLOC is running — accessible on your network.
31953            <span class="sx-238af6bc" id="server-tip-ping" ></span>
31954          </div>
31955        </div>
31956        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31957          <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>
31958        </button>
31959        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31960          <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>
31961          <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>
31962        </button>
31963      </div>
31964    </div>
31965  </div>
31966
31967  <div class="page">
31968    <div class="watched-bar">
31969      <div class="watched-bar-left">
31970        <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>
31971        <span class="watched-label">Watched Folders</span>
31972        <div class="watched-chips">
31973          {% if server_mode %}
31974          <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
31975          {% else %}
31976          {% for dir in watched_dirs %}
31977          <span class="watched-chip">
31978            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
31979            <form class="sx-043808a9" method="POST" action="/watched-dirs/remove" >
31980              <input type="hidden" name="folder_path" value="{{ dir }}">
31981              <input type="hidden" name="redirect_to" value="/compare-scans">
31982              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
31983            </form>
31984          </span>
31985          {% endfor %}
31986          {% if watched_dirs.is_empty() %}
31987          <span class="watched-none">No folders watched — click Choose to add one</span>
31988          {% endif %}
31989          {% endif %}
31990        </div>
31991      </div>
31992      {% if !server_mode %}
31993      <div class="watched-bar-right">
31994        <button type="button" class="btn" id="add-watched-btn">
31995          <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>
31996          Choose
31997        </button>
31998        <form class="sx-043808a9" method="POST" action="/watched-dirs/refresh" >
31999          <input type="hidden" name="redirect_to" value="/compare-scans">
32000          <button type="submit" class="btn">&#8635; Refresh</button>
32001        </form>
32002      </div>
32003      {% endif %}
32004    </div>
32005    <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
32006      <div class="scan-overlay-card">
32007        <div class="scan-spinner"></div>
32008        <div class="scan-overlay-text">Scanning folder…</div>
32009        <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
32010      </div>
32011    </div>
32012    <style nonce="{{ csp_nonce }}">
32013    .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);}
32014    .scan-overlay.active{display:flex;}
32015    .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;}
32016    .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;}
32017    @keyframes scanSpin{to{transform:rotate(360deg);}}
32018    .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
32019    .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
32020    </style>
32021    {% if total_scans > 0 %}
32022    <div class="summary-strip">
32023      <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>
32024      <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>
32025      <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>
32026      <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>
32027    </div>
32028    {% endif %}
32029    <section class="panel">
32030      <div class="panel-header">
32031        <div>
32032          <h1>Compare Scans</h1>
32033          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
32034        </div>
32035        <div class="sx-fde5ebae" >
32036          <div class="sx-a6b122c5" >
32037            <button class="btn primary" id="compare-btn" disabled>
32038              <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>
32039              Compare <span class="sel-count" id="sel-count">0</span> Selected
32040            </button>
32041          </div>
32042        </div>
32043      </div>
32044
32045      {% if entries.is_empty() %}
32046      <div class="empty-state">
32047        <strong>No scans yet</strong>
32048        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.
32049      </div>
32050      {% else %}
32051      <div class="filter-row">
32052        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name&hellip;">
32053        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
32054        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
32055      </div>
32056      <div class="scope-panel hidden" id="scope-panel">
32057        <div class="scope-panel-label">
32058          <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>
32059          Compare scope — choose what to include
32060        </div>
32061        <div class="scope-options" id="scope-options"></div>
32062      </div>
32063      {% if total_scans > 0 %}
32064      <div class="hint-right-wrap sx-f9538e2a" >
32065        <div class="instruction-bar sx-2f0d883e" >
32066          <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>
32067          Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
32068        </div>
32069      </div>
32070      {% endif %}
32071      <div id="compare-all-bar" class="compare-all-bar sx-6aa34d74" >
32072        <span class="compare-all-label">
32073          <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>
32074          Quick Compare All
32075        </span>
32076      </div>
32077      <div class="table-wrap">
32078        <table id="compare-table">
32079          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
32080          <thead>
32081            <tr id="compare-thead">
32082              <th><div class="col-resize-handle"></div></th>
32083              <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>
32084              <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>
32085              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
32086              <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>
32087              <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>
32088              <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>
32089              <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>
32090              <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>
32091              <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>
32092              <th>Submodules<div class="col-resize-handle"></div></th>
32093            </tr>
32094          </thead>
32095          <tbody id="compare-tbody">
32096            {% for entry in entries %}
32097            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
32098                data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
32099                data-project="{{ entry.project_label }}"
32100                data-files="{{ entry.files_analyzed }}"
32101                data-code="{{ entry.code_lines }}"
32102                data-comments="{{ entry.comment_lines }}"
32103                data-blank="{{ entry.blank_lines }}"
32104                data-branch="{{ entry.git_branch }}"
32105                data-commit="{{ entry.git_commit }}"
32106                data-submodules="{{ entry.submodule_names_csv }}">
32107              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
32108              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
32109              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
32110              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
32111              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
32112              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
32113              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
32114              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
32115              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="sx-eac76940" >&#8212;</span>{% endif %}</td>
32116              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip sx-53f53688"  data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span class="sx-eac76940" >&#8212;</span>{% endif %}</td>
32117              <td class="sx-bf562f19" >{% 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 class="sx-eac76940" >&#8212;</span>{% endif %}</td>
32118            </tr>
32119            {% endfor %}
32120          </tbody>
32121        </table>
32122      </div>
32123      <div class="pagination">
32124        <span class="pagination-info" id="pagination-info"></span>
32125        <div class="pagination-btns" id="pagination-btns"></div>
32126        <div class="flex-row">
32127          <span class="per-page-label">Show</span>
32128          <select class="per-page" id="per-page-sel">
32129            <option value="10">10 per page</option>
32130            <option value="25" selected>25 per page</option>
32131            <option value="50">50 per page</option>
32132            <option value="100">100 per page</option>
32133          </select>
32134          <span class="per-page-label" id="page-range-label"></span>
32135        </div>
32136      </div>
32137      {% endif %}
32138    </section>
32139  </div>
32140
32141  <footer class="site-footer">
32142    local code analysis - metrics, history and reports
32143    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
32144    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32145    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32146    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32147    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
32148  </footer>
32149
32150  <script nonce="{{ csp_nonce }}">
32151    (function () {
32152      // ── Theme ──────────────────────────────────────────────────────────────
32153      var storageKey = 'oxide-sloc-theme';
32154      var body = document.body;
32155      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
32156      var toggle = document.getElementById('theme-toggle');
32157      if (toggle) toggle.addEventListener('click', function () {
32158        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
32159        body.classList.toggle('dark-theme', next === 'dark');
32160        try { localStorage.setItem(storageKey, next); } catch(e) {}
32161      });
32162
32163      // ── State ─────────────────────────────────────────────────────────────
32164      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
32165      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
32166      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
32167      window._allCompareRows = allRows;
32168
32169      // ── Stat chips ────────────────────────────────────────────────────────
32170      (function() {
32171        var projects = {}, latestTs = '', latestRow = null;
32172        allRows.forEach(function(r) {
32173          var p = r.dataset.project || ''; if (p) projects[p] = true;
32174          var ts = r.dataset.timestamp || '';
32175          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
32176        });
32177        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();}
32178        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>':'');}
32179        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
32180        if (latestRow) {
32181          setChipVal('agg-code', latestRow.dataset.code);
32182          setChipVal('agg-files', latestRow.dataset.files);
32183        }
32184        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(); });
32185      })();
32186
32187      // ── Branch filter population ──────────────────────────────────────────
32188      (function() {
32189        var branches = {};
32190        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
32191        var sel = document.getElementById('branch-filter');
32192        if (sel) Object.keys(branches).sort().forEach(function(b) {
32193          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
32194        });
32195      })();
32196
32197      // ── Filter ────────────────────────────────────────────────────────────
32198      function getFilteredRows() {
32199        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
32200        var branch = ((document.getElementById('branch-filter') || {}).value || '');
32201        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
32202          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
32203          if (branch && (r.dataset.branch || '') !== branch) return false;
32204          return true;
32205        });
32206      }
32207
32208      // ── Pagination ────────────────────────────────────────────────────────
32209      function renderPage() {
32210        var filtered = getFilteredRows();
32211        var total = filtered.length;
32212        var totalPages = Math.max(1, Math.ceil(total / perPage));
32213        currentPage = Math.min(currentPage, totalPages);
32214        var start = (currentPage - 1) * perPage;
32215        var end = Math.min(start + perPage, total);
32216        var shown = {};
32217        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
32218        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
32219          r.style.display = shown[r.dataset.run] ? '' : 'none';
32220        });
32221        var rl = document.getElementById('page-range-label');
32222        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
32223        var info = document.getElementById('pagination-info');
32224        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
32225        var btns = document.getElementById('pagination-btns');
32226        if (!btns) return;
32227        btns.innerHTML = '';
32228        function makeBtn(lbl, pg, active, disabled) {
32229          var b = document.createElement('button');
32230          b.className = 'pg-btn' + (active ? ' active' : '');
32231          b.textContent = lbl; b.disabled = disabled;
32232          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
32233          return b;
32234        }
32235        btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
32236        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
32237        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
32238        btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
32239      }
32240
32241      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
32242      window.applyFilters = function() { currentPage = 1; renderPage(); };
32243
32244      // ── Sorting ───────────────────────────────────────────────────────────
32245      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
32246      function doSort(col, type, order) {
32247        var tbody = document.getElementById('compare-tbody');
32248        if (!tbody) return;
32249        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
32250        rows.sort(function(a, b) {
32251          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
32252          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
32253          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
32254          return va < vb ? 1 : va > vb ? -1 : 0;
32255        });
32256        rows.forEach(function(r) { tbody.appendChild(r); });
32257        currentPage = 1; renderPage();
32258      }
32259      sortHeaders.forEach(function(th) {
32260        th.addEventListener('click', function(e) {
32261          if (e.target.classList.contains('col-resize-handle')) return;
32262          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
32263          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
32264          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
32265          th.classList.add('sort-' + sortOrder);
32266          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
32267          doSort(col, type, sortOrder);
32268        });
32269      });
32270
32271      // Apply default sort (timestamp desc) on initial load
32272      (function() {
32273        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
32274        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
32275      })();
32276
32277      // ── Column resize ─────────────────────────────────────────────────────
32278      (function() {
32279        var table = document.getElementById('compare-table');
32280        if (!table) return;
32281        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
32282        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
32283        ths.forEach(function(th, i) {
32284          var handle = th.querySelector('.col-resize-handle');
32285          if (!handle || !cols[i]) return;
32286          var startX, startW;
32287          handle.addEventListener('mousedown', function(e) {
32288            e.stopPropagation(); e.preventDefault();
32289            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
32290            handle.classList.add('dragging');
32291            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
32292            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
32293            document.addEventListener('mousemove', onMove);
32294            document.addEventListener('mouseup', onUp);
32295          });
32296        });
32297      })();
32298
32299      // ── Full-commit hover tooltip ─────────────────────────────────────────
32300      // The commit chips live inside an overflow:auto table wrapper, which would
32301      // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
32302      // (escaping the scroll container) and follow the cursor. Event delegation
32303      // keeps it working after pagination/sorting re-renders the rows.
32304      (function() {
32305        var tip = document.createElement('div');
32306        tip.className = 'commit-tip';
32307        tip.setAttribute('role', 'tooltip');
32308        document.body.appendChild(tip);
32309        var shown = false;
32310        function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
32311        function place(e) {
32312          var pad = 14, r = tip.getBoundingClientRect();
32313          var x = e.clientX + pad, y = e.clientY + pad;
32314          if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
32315          if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
32316          tip.style.left = x + 'px'; tip.style.top = y + 'px';
32317        }
32318        function hide() { tip.style.display = 'none'; shown = false; }
32319        document.addEventListener('mouseover', function(e) {
32320          var chip = chipFrom(e.target);
32321          if (!chip) return;
32322          var full = chip.getAttribute('data-full-commit');
32323          if (!full) return;
32324          tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
32325        });
32326        document.addEventListener('mousemove', function(e) {
32327          if (!shown) return;
32328          if (chipFrom(e.target)) place(e); else hide();
32329        });
32330        document.addEventListener('mouseout', function(e) {
32331          if (chipFrom(e.target)) hide();
32332        });
32333      })();
32334
32335      // ── Reset view ────────────────────────────────────────────────────────
32336      window.resetView = function() {
32337        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
32338        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
32339        sortCol = null; sortOrder = 'asc';
32340        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
32341        var tbody = document.getElementById('compare-tbody');
32342        if (tbody) {
32343          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
32344          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
32345          rows.forEach(function(r) { tbody.appendChild(r); });
32346        }
32347        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
32348        var table = document.getElementById('compare-table');
32349        currentPage = 1; renderPage();
32350        currentPage = 1; renderPage();
32351      };
32352
32353      renderPage();
32354      buildCompareAllBar();
32355
32356      // ── Row selection state ───────────────────────────────────────────────
32357      var selected = [];
32358      var lockedProject = null; // project label of first selected scan
32359
32360      function updateCompareBtn() {
32361        var btn = document.getElementById('compare-btn');
32362        var cnt = document.getElementById('sel-count');
32363        if (!btn) return;
32364        btn.disabled = selected.length < 2;
32365        if (cnt) cnt.textContent = selected.length;
32366      }
32367
32368      function applyProjectLock() {
32369        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
32370        allRows.forEach(function(r) {
32371          if (lockedProject === null) {
32372            r.classList.remove('row-locked');
32373          } else {
32374            var proj = r.dataset.project || '';
32375            if (proj !== lockedProject) {
32376              r.classList.add('row-locked');
32377            } else {
32378              r.classList.remove('row-locked');
32379            }
32380          }
32381        });
32382      }
32383
32384      function toggleRow(row) {
32385        if (row.classList.contains('row-locked')) return;
32386        var vid = row.dataset.vid || row.dataset.run;
32387        var idx = selected.indexOf(vid);
32388        if (idx >= 0) {
32389          selected.splice(idx, 1);
32390          row.classList.remove('selected');
32391          var b = document.getElementById('badge-' + vid);
32392          if (b) b.textContent = '';
32393          // Release project lock if nothing selected
32394          if (selected.length === 0) lockedProject = null;
32395        } else {
32396          // Set project lock on first selection
32397          if (selected.length === 0) lockedProject = row.dataset.project || null;
32398          selected.push(vid);
32399          row.classList.add('selected');
32400        }
32401        selected.forEach(function(v, i) {
32402          var b = document.getElementById('badge-' + v);
32403          if (b) b.textContent = i + 1;
32404        });
32405        applyProjectLock();
32406        updateCompareBtn();
32407        buildScopePanel();
32408      }
32409
32410      // ── Compare-All bar ───────────────────────────────────────────────────
32411      function buildCompareAllBar() {
32412        var bar = document.getElementById('compare-all-bar');
32413        if (!bar) return;
32414        // Group all rows by project label.
32415        var groups = {};
32416        var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
32417        // Use all rows from the source data (not just visible).
32418        var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
32419        // We need ALL rows across all pages, not just the rendered ones.
32420        // Use the underlying allRows array that the pagination JS also uses.
32421        var sourceRows = window._allCompareRows || allRowsAll;
32422        sourceRows.forEach(function(r) {
32423          var proj = r.dataset.project || '';
32424          var vid = r.dataset.vid || r.dataset.run || '';
32425          if (!proj || !vid) return;
32426          if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
32427          groups[proj].ids.push(vid);
32428          groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
32429        });
32430        // Build buttons for each project with >= 2 scans.
32431        var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
32432        if (!keys.length) { bar.style.display = 'none'; return; }
32433        bar.style.display = 'flex';
32434        // Remove old buttons (keep label).
32435        var oldBtns = bar.querySelectorAll('.compare-all-btn');
32436        oldBtns.forEach(function(b) { b.remove(); });
32437        keys.sort();
32438        keys.forEach(function(proj) {
32439          var g = groups[proj];
32440          var btn = document.createElement('button');
32441          btn.className = 'compare-all-btn';
32442          btn.type = 'button';
32443          btn.textContent = proj + ' (' + g.ids.length + ' scans)';
32444          btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
32445          btn.addEventListener('click', function() {
32446            // Sort ids by timestamp (ascending).
32447            var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
32448            pairs.sort(function(a, b) { return a.ts - b.ts; });
32449            var sorted = pairs.map(function(p) { return p.id; });
32450            if (sorted.length === 2) {
32451              window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
32452            } else {
32453              window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
32454            }
32455          });
32456          bar.appendChild(btn);
32457        });
32458      }
32459
32460      // ── Scope panel ───────────────────────────────────────────────────────
32461      var selectedScope = 'all';
32462
32463      function buildScopePanel() {
32464        var panel = document.getElementById('scope-panel');
32465        var opts = document.getElementById('scope-options');
32466        if (!panel || !opts) return;
32467        if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
32468
32469        // Collect union of submodules from all selected rows.
32470        var allSubs = {};
32471        selected.forEach(function(vid) {
32472          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
32473          if (!row) return;
32474          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
32475        });
32476        var subList = Object.keys(allSubs).sort();
32477        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
32478
32479        panel.classList.remove('hidden');
32480        opts.innerHTML = '';
32481
32482        function makeOption(value, label, title) {
32483          var div = document.createElement('div');
32484          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
32485          div.dataset.scopeValue = value;
32486          if (title) div.title = title;
32487          var radio = document.createElement('span');
32488          radio.className = 'scope-option-radio';
32489          var lbl = document.createElement('span');
32490          lbl.textContent = label;
32491          div.appendChild(radio);
32492          div.appendChild(lbl);
32493          div.addEventListener('click', function() {
32494            selectedScope = value;
32495            opts.querySelectorAll('.scope-option').forEach(function(o) {
32496              o.classList.toggle('selected', o.dataset.scopeValue === value);
32497            });
32498          });
32499          return div;
32500        }
32501
32502        opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
32503        var sep = document.createElement('span');
32504        sep.className = 'scope-option-sep';
32505        opts.appendChild(sep);
32506        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
32507        subList.forEach(function(s) {
32508          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
32509        });
32510      }
32511
32512      function doCompare() {
32513        if (selected.length < 2) return;
32514        if (selected.length === 2) {
32515          // Two-scan delta (existing flow with scope support).
32516          var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
32517          if (selectedScope === 'super') url += '&scope=super';
32518          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
32519          window.location.href = url;
32520        } else {
32521          // Multi-scan timeline (N >= 3) — pass scope params too.
32522          var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
32523          if (selectedScope === 'super') url += '&scope=super';
32524          else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
32525          window.location.href = url;
32526        }
32527      }
32528
32529      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
32530      var cbtn = document.getElementById('compare-btn');
32531      if (cbtn) cbtn.addEventListener('click', doCompare);
32532      var pfEl = document.getElementById('project-filter');
32533      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
32534      var bfEl = document.getElementById('branch-filter');
32535      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
32536      var rvBtn = document.getElementById('reset-view-btn');
32537      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
32538      var ppSel = document.getElementById('per-page-sel');
32539      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
32540
32541      var cmpTbody = document.getElementById('compare-tbody');
32542      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
32543        var row = e.target.closest('.compare-row');
32544        if (row) toggleRow(row);
32545      });
32546
32547      (function randomizeWatermarks() {
32548        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32549        if (!wms.length) return;
32550        var placed = [];
32551        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;}
32552        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];}
32553        var half=Math.floor(wms.length/2);
32554        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;});
32555      })();
32556
32557      (function spawnCodeParticles() {
32558        var container = document.getElementById('code-particles');
32559        if (!container) return;
32560        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
32561        for (var i = 0; i < 44; i++) {
32562          (function(idx) {
32563            var el = document.createElement('span');
32564            el.className = 'code-particle';
32565            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
32566            var left = Math.random() * 94 + 2;
32567            var top = Math.random() * 88 + 6;
32568            var dur = (Math.random() * 10 + 9).toFixed(1);
32569            var delay = (Math.random() * 18).toFixed(1);
32570            var rot = (Math.random() * 26 - 13).toFixed(1);
32571            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
32572            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';
32573            container.appendChild(el);
32574          })(i);
32575        }
32576      })();
32577
32578      // ── Watched folder picker ─────────────────────────────────────────────
32579      (function(){
32580        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');};
32581        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);
32582      })();
32583      (function() {
32584        var btn = document.getElementById('add-watched-btn');
32585        if (!btn) return;
32586        btn.addEventListener('click', function() {
32587          fetch('/pick-directory?kind=reports')
32588            .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
32589            .then(function(data) {
32590              if (!data.cancelled && data.selected_path) {
32591                var form = document.createElement('form');
32592                form.method = 'POST';
32593                form.action = '/watched-dirs/add';
32594                var ri = document.createElement('input');
32595                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
32596                var fi = document.createElement('input');
32597                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
32598                form.appendChild(ri); form.appendChild(fi);
32599                document.body.appendChild(form);
32600                if (window.__scanOverlay) window.__scanOverlay();
32601                form.submit();
32602              }
32603            })
32604            .catch(function(e) { alert('Could not open folder picker: ' + e); });
32605        });
32606      })();
32607
32608      // ── Submodule chip truncation ─────────────────────────────────────────
32609      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
32610        var chips = cell.querySelectorAll('.submod-chip');
32611        var MAX = 4;
32612        if (chips.length <= MAX) return;
32613        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
32614        var badge = document.createElement('span');
32615        badge.className = 'submod-overflow-badge';
32616        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
32617        badge.textContent = '+' + (chips.length - MAX) + ' more';
32618        cell.appendChild(badge);
32619        cell.style.maxHeight = 'none';
32620      });
32621    })();
32622  </script>
32623  <script nonce="{{ csp_nonce }}">
32624  (function(){
32625    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'}];
32626    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);});}
32627    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32628    function init(){
32629      var btn=document.getElementById('settings-btn');if(!btn)return;
32630      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32631      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
32632      document.body.appendChild(m);
32633      var g=document.getElementById('scheme-grid');
32634      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);});
32635      var cl=document.getElementById('settings-close');
32636      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);});})();
32637      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');});
32638      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32639      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32640    }
32641    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
32642  }());
32643  </script>
32644  <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]';
32645  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;}
32646  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>
32647</body>
32648</html>
32649"##,
32650    ext = "html"
32651)]
32652struct CompareSelectTemplate {
32653    version: &'static str,
32654    entries: Vec<HistoryEntryRow>,
32655    total_scans: usize,
32656    watched_dirs: Vec<String>,
32657    csp_nonce: String,
32658    server_mode: bool,
32659}
32660
32661// ── CompareTemplate ────────────────────────────────────────────────────────────
32662
32663#[derive(Template)]
32664#[template(
32665    source = r##"
32666<!doctype html>
32667<html lang="en">
32668<head>
32669  <meta charset="utf-8">
32670  <meta name="viewport" content="width=device-width, initial-scale=1">
32671  <title>OxideSLOC | Scan Delta</title>
32672  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
32673  <link rel="stylesheet" href="/static/app.css">
32674  <script src="/static/app.js"></script>
32675  <style nonce="{{ csp_nonce }}">
32676    :root {
32677      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
32678      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
32679      --nav:#283790; --nav-2:#013e6b;
32680      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
32681      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
32682      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
32683    }
32684    body.dark-theme {
32685      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
32686      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
32687    }
32688    *{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;}
32689    .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);}
32690    .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;}
32691    .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));}
32692    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
32693    .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;}
32694    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
32695    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
32696    @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; } }
32697    .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;}
32698    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
32699    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
32700    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
32701    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
32702    .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;}
32703    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
32704    .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);}
32705    .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;}
32706    .settings-close:hover{color:var(--text);background:var(--surface-2);}
32707    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
32708    .settings-modal-body{padding:14px 16px 16px;}
32709    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
32710    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
32711    .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;}
32712    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
32713    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
32714    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
32715    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
32716    .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;}
32717    .tz-select:focus{border-color:var(--oxide);}
32718    .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
32719    @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
32720    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
32721    .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;}
32722    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
32723    .hero-body{display:block;}
32724    .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;}
32725    .btn-back:hover{background:var(--line);}
32726    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
32727    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
32728    .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;}
32729    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
32730    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;}
32731    .muted{color:var(--muted);font-size:14px;}
32732    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
32733    .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;}
32734    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
32735    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
32736    .vpill-arrow{font-size:20px;color:var(--muted);}
32737    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
32738    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
32739    .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;}
32740    .delta-card.delta-card-wide{padding:22px 24px;}
32741    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
32742    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
32743    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
32744    .delta-card-from{font-size:15px;color:var(--muted);}
32745    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
32746    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
32747    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
32748    .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%;}
32749    .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;}
32750    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
32751    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
32752    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
32753    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
32754    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
32755    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
32756    .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;}
32757    .meta-card-commit:hover{color:var(--oxide);}
32758    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
32759    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
32760    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
32761    .meta-value{color:var(--text);font-size:13px;}
32762    .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
32763    .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;}
32764    .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);}
32765    .delta-card:hover .dc-tip{display:block;}
32766    .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;}
32767    .export-btn:hover{background:var(--line);}
32768    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
32769    .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
32770    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
32771    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
32772    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
32773    .delta-card-change.zero{color:var(--muted);background:transparent;}
32774    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
32775    .delta-card-pct.pos{color:var(--pos);}
32776    .delta-card-pct.neg{color:var(--neg);}
32777    .delta-card-pct.zero{color:var(--muted);}
32778    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
32779    .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;}
32780    .insight-card.insight-flag{border-color:var(--oxide);}
32781    .insight-card:hover .dc-tip{display:block;}
32782    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
32783    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
32784    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
32785    .insight-label.flag{color:var(--oxide);}
32786    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
32787    .insight-val.pos{color:var(--pos);}
32788    .insight-val.neg{color:var(--neg);}
32789    .insight-val.high{color:#c0392a;}
32790    .insight-val.med{color:#926000;}
32791    .insight-val.low{color:var(--pos);}
32792    body.dark-theme .insight-val.high{color:#ff6b6b;}
32793    body.dark-theme .insight-val.med{color:#f0c060;}
32794    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
32795    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
32796    .fc-row{display:flex;align-items:center;gap:8px;}
32797    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
32798    .fc-label{color:var(--muted);}
32799    .fc-modified .fc-count{color:#926000;}
32800    .fc-added .fc-count{color:var(--pos);}
32801    .fc-removed .fc-count{color:var(--neg);}
32802    .fc-unchanged .fc-count{color:var(--muted);}
32803    .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
32804    .fc-total .fc-count{color:var(--text);}
32805    .fc-total .fc-label{font-weight:700;}
32806    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
32807    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
32808    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
32809    .chip.modified{background:#fff2d8;color:#926000;}
32810    .chip.added{background:#e8f5ed;color:#1a8f47;}
32811    .chip.removed{background:#fdeaea;color:#b33b3b;}
32812    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
32813    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
32814    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
32815    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
32816    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
32817    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
32818    .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;}
32819    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
32820    .tab-btn:hover:not(.active){background:var(--line);}
32821    .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;}
32822    .btn-reset:hover{background:var(--line);}
32823    .table-wrap{width:100%;overflow-x:auto;}
32824    table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
32825    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);}
32826    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
32827    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
32828    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
32829    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
32830    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
32831    td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
32832    tr:last-child td{border-bottom:none;}
32833    tr:hover td{background:var(--surface-2);}
32834    .col-num{text-align:right;font-variant-numeric:tabular-nums;}
32835    #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
32836    #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
32837    /* Fixed layout: column widths come from the colgroup, not from scanning every
32838       row. With auto layout a large file matrix forces the browser to re-measure
32839       all cells on each reflow, which freezes the page during sort/resize. */
32840    #delta-table{table-layout:fixed;}
32841    #delta-table col:nth-child(1){width:32%;}
32842    #delta-table col:nth-child(2){width:11%;}
32843    #delta-table col:nth-child(3){width:11%;}
32844    #delta-table col:nth-child(4){width:16%;}
32845    #delta-table col:nth-child(5){width:10%;}
32846    #delta-table col:nth-child(6){width:10%;}
32847    #delta-table col:nth-child(7){width:10%;}
32848    tr.row-added td{background:rgba(26,143,71,0.04);}
32849    tr.row-removed td{background:rgba(179,59,59,0.06);}
32850    tr.row-modified td{background:rgba(146,96,0,0.04);}
32851    tr.row-unchanged td{color:var(--muted);}
32852    tr.row-unchanged .status-badge{opacity:.65;}
32853    .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;}
32854    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
32855    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
32856    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
32857    .status-badge.modified{background:#fff2d8;color:#926000;}
32858    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
32859    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
32860    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
32861    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
32862    .delta-val{font-weight:700;}
32863    .delta-val.pos{color:var(--pos);}
32864    .delta-val.neg{color:var(--neg);}
32865    .delta-val.zero{color:var(--muted);}
32866    .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
32867    .from-to strong{color:var(--text);font-weight:700;}
32868    .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
32869    .from-to .ft-absent{color:var(--muted);font-weight:600;}
32870    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
32871    .site-footer a{color:var(--muted);}
32872    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;}
32873    body.pdf-mode{background:#fff!important;}
32874    body.pdf-mode .page{padding:4px 6px 4px!important;}
32875    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
32876    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
32877    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
32878    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
32879    .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;}
32880    .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;}
32881    .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
32882    @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));}}
32883    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
32884    .path-link:hover{color:var(--oxide-2);}
32885    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
32886    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
32887    a.vpill-id:hover{color:var(--oxide);}
32888    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
32889    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
32890    .pagination-info{font-size:13px;color:var(--muted);}
32891    .pagination-btns{display:flex;gap:6px;}
32892    .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;}
32893    .pg-btn:hover:not(:disabled){background:var(--line);}
32894    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32895    .pg-btn:disabled{opacity:.35;cursor:default;}
32896    .per-page-label{font-size:13px;color:var(--muted);}
32897    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;}
32898    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32899    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
32900    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
32901    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
32902    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
32903    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
32904    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
32905    .tab-btn.tab-unchanged{color:var(--muted);}
32906    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
32907    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
32908    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
32909    .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;}
32910    .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;}
32911    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
32912    .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;}
32913    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
32914    .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;}
32915    .submod-scope-btn:hover{background:var(--line);}
32916    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32917    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
32918    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
32919    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
32920    .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
32921    body.dark-theme .ic-card{background:var(--surface-2);}
32922    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
32923    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
32924    .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
32925    .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
32926    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
32927    .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);}
32928    .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
32929    .ic-card-h2-row .ic-card-h2{margin:0;}
32930    .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;}
32931    .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
32932    .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;}
32933    .ic-svg-modal-ov.open{display:flex;}
32934    .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);}
32935    body.dark-theme .ic-svg-modal{background:var(--surface-2);}
32936    .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);}
32937    .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
32938    .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;}
32939    .ic-svg-modal-close:hover{background:var(--line);}
32940    .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;}
32941    .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32942    .chart-metric-btn:hover:not(.active){background:var(--line);}
32943    .chart-wrap{width:100%;overflow-x:auto;}
32944    #cmp-tl-svg{display:block;width:100%;}
32945    .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);}
32946    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
32947    #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;}
32948  </style>
32949</head>
32950<body>
32951  {{ loading_overlay|safe }}
32952  <div class="background-watermarks" aria-hidden="true">
32953    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32954    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32955    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32956    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32957    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32958    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32959  </div>
32960  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
32961  <div class="top-nav">
32962    <div class="top-nav-inner">
32963      <a class="brand" href="/">
32964        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
32965        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
32966      </a>
32967      <div class="nav-right">
32968        <a class="nav-pill" href="/">Home</a>
32969        <div class="nav-dropdown">
32970          <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>
32971          <div class="nav-dropdown-menu">
32972            <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>
32973          </div>
32974        </div>
32975        <a class="nav-pill sx-8c38ef73"  href="/compare-scans">Compare Scans</a>
32976        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
32977        <div class="nav-dropdown">
32978          <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>
32979          <div class="nav-dropdown-menu">
32980            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
32981            <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>
32982          </div>
32983        </div>
32984        <div class="server-status-wrap" id="server-status-wrap">
32985          <div class="nav-pill server-online-pill" id="server-status-pill">
32986            <span class="status-dot" id="status-dot"></span>
32987            <span id="server-status-label">Server</span>
32988            <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
32989          </div>
32990          <div class="server-status-tip">
32991            OxideSLOC is running — accessible on your network.
32992            <span class="sx-238af6bc" id="server-tip-ping" ></span>
32993          </div>
32994        </div>
32995        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
32996          <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>
32997        </button>
32998        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
32999          <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>
33000          <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>
33001        </button>
33002      </div>
33003    </div>
33004  </div>
33005
33006  <div class="page">
33007    <section class="hero">
33008      <div class="hero-header">
33009        <div>
33010          <h1 class="delta-title">Scan Delta</h1>
33011          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
33012          <div class="sx-3d0e741f" >
33013            {% if let Some(sub) = active_submodule %}
33014            <span class="muted sx-43e9c079" >Submodule <strong>{{ sub }}</strong> — two scans of</span>
33015            {% else if super_scope_active %}
33016            <span class="muted sx-43e9c079" >Super-repo only (submodules excluded) — two scans of</span>
33017            {% else %}
33018            <span class="muted sx-43e9c079" >Full scan — two scans of</span>
33019            {% endif %}
33020            <a class="path-link sx-eac12633" id="project-path-link" data-folder="{{ project_path }}" href="#" >{{ project_path }}</a>
33021          </div>
33022        </div>
33023        <div class="sx-543fb39c" >
33024          <a class="btn-back" href="/compare-scans">
33025            <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>
33026            Compare Scans
33027          </a>
33028          <div class="export-group sx-b6d781cb" >
33029            <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>
33030            <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>
33031          </div>
33032        </div>
33033      </div>
33034      {% if has_any_submodule_data %}
33035      <div class="submod-scope-bar">
33036        <span class="submod-scope-label">
33037          <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>
33038          Scope:
33039        </span>
33040        <div class="submod-scope-divider"></div>
33041        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
33042           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
33043           title="All files — super-repo and all submodules combined">Full scan</a>
33044        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
33045           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
33046           title="Only files that are not part of any submodule">Super-repo only</a>
33047        {% for sub in submodule_options %}
33048        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
33049           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
33050           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
33051        {% endfor %}
33052      </div>
33053      {% endif %}
33054      <div class="hero-body">
33055      <div class="meta-strip">
33056        <div class="delta-card delta-card-meta">
33057          <div class="meta-card-header">
33058            <div class="delta-card-label sx-d7014559" >Baseline</div>
33059            <div class="meta-card-project-col">
33060              <div class="meta-card-project">{{ project_name }}</div>
33061              {% if has_any_submodule_data %}
33062              {% if let Some(sub) = active_submodule %}
33063              <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>
33064              {% else if super_scope_active %}
33065              <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>
33066              {% else %}
33067              <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>
33068              {% endif %}
33069              {% endif %}
33070            </div>
33071          </div>
33072          {% if !baseline_git_commit.is_empty() %}
33073          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
33074          {% else %}
33075          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
33076          {% endif %}
33077          <div class="meta-card-rows">
33078            <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>
33079            <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>
33080            <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>
33081            <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>
33082            <div class="meta-card-row"><span class="meta-label">Scanned by:</span>{% if !baseline_performed_by.is_empty() %}<span class="git-chip">{{ baseline_performed_by }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
33083            {% if let Some(tags) = baseline_git_tags %}
33084            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
33085            {% endif %}
33086          </div>
33087        </div>
33088        <div class="delta-card delta-card-meta">
33089          <div class="meta-card-header">
33090            <div class="delta-card-label sx-d7014559" >Current</div>
33091            <div class="meta-card-project-col">
33092              <div class="meta-card-project">{{ project_name }}</div>
33093              {% if has_any_submodule_data %}
33094              {% if let Some(sub) = active_submodule %}
33095              <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>
33096              {% else if super_scope_active %}
33097              <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>
33098              {% else %}
33099              <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>
33100              {% endif %}
33101              {% endif %}
33102            </div>
33103          </div>
33104          {% if !current_git_commit.is_empty() %}
33105          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
33106          {% else %}
33107          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
33108          {% endif %}
33109          <div class="meta-card-rows">
33110            <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>
33111            <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>
33112            <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>
33113            <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>
33114            <div class="meta-card-row"><span class="meta-label">Scanned by:</span>{% if !current_performed_by.is_empty() %}<span class="git-chip">{{ current_performed_by }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
33115            {% if let Some(tags) = current_git_tags %}
33116            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
33117            {% endif %}
33118          </div>
33119        </div>
33120      </div>
33121      <div class="delta-strip">
33122        <div class="delta-card">
33123          <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
33124          <div class="delta-card-label">Code lines</div>
33125          <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
33126          <div class="delta-card-to">{{ current_code_fmt }}</div>
33127          {% 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>
33128          {% 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>
33129          {% else %}<div class="delta-card-pct zero">±0%</div>
33130          {% endif %}
33131        </div>
33132        <div class="delta-card">
33133          <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
33134          <div class="delta-card-label">Files analyzed</div>
33135          <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
33136          <div class="delta-card-to">{{ current_files_fmt }}</div>
33137          {% 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>
33138          {% 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>
33139          {% else %}<div class="delta-card-pct zero">±0%</div>
33140          {% endif %}
33141        </div>
33142        <div class="delta-card">
33143          <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>
33144          <div class="delta-card-label">Comment lines</div>
33145          <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
33146          <div class="delta-card-to">{{ current_comments_fmt }}</div>
33147          {% 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>
33148          {% 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>
33149          {% else %}<div class="delta-card-pct zero">±0%</div>
33150          {% endif %}
33151        </div>
33152        {{ coverage_delta_card|safe }}
33153        <div class="delta-card delta-card-wide">
33154          <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>
33155          <div class="delta-card-label">File changes</div>
33156          <div class="file-changes-grid">
33157            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
33158            <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
33159            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
33160            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
33161            <div class="fc-row fc-total"><span class="fc-count">{{ files_total|commas }}</span><span class="fc-label">Total (modified + added + removed + unchanged)</span></div>
33162          </div>
33163        </div>
33164      </div>
33165      <div class="insights-panel">
33166        <div class="insight-card">
33167          <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>
33168          <div class="insight-label">Lines Added</div>
33169          <div class="insight-val pos">+{{ code_lines_added }}</div>
33170          <div class="insight-sub">New or grown source lines</div>
33171        </div>
33172        <div class="insight-card">
33173          <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>
33174          <div class="insight-label">Lines Removed</div>
33175          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
33176          <div class="insight-sub">Deleted or shrunk source lines</div>
33177        </div>
33178        <div class="insight-card">
33179          <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>
33180          <div class="insight-label">Lines Modified</div>
33181          <div class="insight-val">{{ code_lines_modified }}</div>
33182          <div class="insight-sub">Code lines in modified files</div>
33183        </div>
33184        <div class="insight-card">
33185          <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>
33186          <div class="insight-label">Lines Unmodified</div>
33187          <div class="insight-val">{{ code_lines_unmodified }}</div>
33188          <div class="insight-sub">Code lines in unchanged files</div>
33189        </div>
33190        <div class="insight-card">
33191          <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
33192          <div class="insight-label">Lines Total</div>
33193          <div class="insight-val">{{ code_lines_total }}</div>
33194          <div class="insight-sub">Added + removed + modified + unmodified</div>
33195        </div>
33196        <div class="insight-card">
33197          <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>
33198          <div class="insight-label">Churn Rate</div>
33199          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
33200          <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>
33201        </div>
33202        {% if scope_flag %}
33203        <div class="insight-card insight-flag">
33204          <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>
33205          <div class="insight-label flag">Scope Signal</div>
33206          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
33207          <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>
33208        </div>
33209        {% endif %}
33210      </div>
33211      </div>
33212    </section>
33213
33214    <section class="panel" id="inline-charts-section">
33215      <div class="panel-title">Scan Delta Charts</div>
33216      <div class="ic-grid">
33217        <div class="ic-card sx-aeb7cdee" >
33218          <div class="ic-card-h2-row">
33219            <span class="ic-card-h2">Timeline</span>
33220            <div class="cmp-tl-btns sx-98cced4e" >
33221              <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
33222              <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
33223              <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
33224              <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
33225              <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
33226            </div>
33227            <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">&#x2922; Full View</button>
33228          </div>
33229          <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
33230        </div>
33231        <div class="ic-card">
33232          <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>
33233          <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot sx-ec93ae6b" ></span><span class="sx-d50d9131" >Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot sx-bbb79db3" ></span><span class="sx-f6800712" >Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot sx-6353c5b0" ></span><span class="sx-45650046" >Comments</span></span></div>
33234          <div id="ic-c1"></div>
33235        </div>
33236        <div class="ic-card" id="ic-lang-card">
33237          <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>
33238          <div id="ic-c3"></div>
33239        </div>
33240        <div class="ic-card">
33241          <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>
33242          <div id="ic-c2"></div>
33243        </div>
33244        <div class="ic-card">
33245          <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>
33246          <div id="ic-c4"></div>
33247        </div>
33248      </div>
33249      <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
33250        <div class="ic-svg-modal">
33251          <div class="ic-svg-modal-hdr">
33252            <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
33253            <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">&times; Close</button>
33254          </div>
33255          <div id="ic-svg-modal-body"></div>
33256        </div>
33257      </div>
33258    </section>
33259
33260    <section class="panel">
33261      <div class="panel-title">File Matrix <span class="sx-8bdabd9b" >{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
33262      <div class="sx-a86a62cc" >
33263        <div class="filter-tabs sx-98cced4e" >
33264          <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
33265          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
33266          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
33267          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
33268          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
33269        </div>
33270        <div class="sx-fde5ebae" >
33271          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
33272          <div class="export-group">
33273            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
33274            <button type="button" class="export-btn" id="delta-csv-btn">
33275              <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>
33276              CSV
33277            </button>
33278            <button type="button" class="export-btn" id="delta-xls-btn">
33279              <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>
33280              Excel
33281            </button>
33282          </div>
33283        </div>
33284      </div>
33285
33286      <div class="table-wrap">
33287      <table id="delta-table">
33288        <colgroup>
33289          <col>
33290          <col>
33291          <col>
33292          <col>
33293          <col>
33294          <col>
33295          <col>
33296        </colgroup>
33297        <thead>
33298          <tr id="delta-thead">
33299            <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>
33300            <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>
33301            <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>
33302            <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>
33303            <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>
33304            <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>
33305            <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>
33306          </tr>
33307        </thead>
33308        <tbody id="delta-tbody">
33309          {% for row in file_rows %}
33310          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
33311              data-path="{{ row.relative_path }}"
33312              data-language="{{ row.language }}"
33313              data-baseline-code="{{ row.baseline_code }}"
33314              data-current-code="{{ row.current_code }}"
33315              data-code-delta="{{ row.code_delta_str }}"
33316              data-comment-delta="{{ row.comment_delta_str }}"
33317              data-total-delta="{{ row.total_delta_str }}"
33318              data-orig-idx="">
33319            <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
33320            <td class="hide-sm">{{ row.language }}</td>
33321            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
33322            <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>
33323            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
33324            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
33325            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
33326          </tr>
33327          {% endfor %}
33328        </tbody>
33329      </table>
33330      </div>
33331      <div class="pagination">
33332        <span class="pagination-info" id="pg-range-label"></span>
33333        <div class="pagination-btns" id="pg-btns"></div>
33334        <div class="flex-row">
33335          <span class="per-page-label">Show</span>
33336          <select class="per-page" id="per-page-sel">
33337            <option value="10">10 per page</option>
33338            <option value="25" selected>25 per page</option>
33339            <option value="50">50 per page</option>
33340            <option value="100">100 per page</option>
33341          </select>
33342        </div>
33343      </div>
33344    </section>
33345  </div>
33346
33347  <div id="ic-tt"></div>
33348
33349  <footer class="site-footer">
33350    local code analysis - metrics, history and reports
33351    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
33352    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
33353    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
33354    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
33355    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
33356  </footer>
33357
33358  <script nonce="{{ csp_nonce }}">
33359    (function () {
33360      var storageKey = 'oxide-sloc-theme';
33361      var body = document.body;
33362      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
33363      var toggle = document.getElementById('theme-toggle');
33364      if (toggle) toggle.addEventListener('click', function () {
33365        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
33366        body.classList.toggle('dark-theme', next === 'dark');
33367        try { localStorage.setItem(storageKey, next); } catch(e) {}
33368      });
33369
33370      (function randomizeWatermarks() {
33371        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
33372        if (!wms.length) return;
33373        var placed = [];
33374        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;}
33375        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];}
33376        var half=Math.floor(wms.length/2);
33377        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;});
33378      })();
33379
33380      (function spawnCodeParticles() {
33381        var container = document.getElementById('code-particles');
33382        if (!container) return;
33383        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
33384        for (var i = 0; i < 44; i++) {
33385          (function(idx) {
33386            var el = document.createElement('span');
33387            el.className = 'code-particle';
33388            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
33389            var left = Math.random() * 94 + 2;
33390            var top = Math.random() * 88 + 6;
33391            var dur = (Math.random() * 10 + 9).toFixed(1);
33392            var delay = (Math.random() * 18).toFixed(1);
33393            var rot = (Math.random() * 26 - 13).toFixed(1);
33394            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
33395            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';
33396            container.appendChild(el);
33397          })(i);
33398        }
33399      })();
33400    })();
33401
33402    var activeStatusFilter = 'all';
33403    var deltaPerPage = 25, deltaCurrPage = 1;
33404
33405    function openFolder(path) {
33406      fetch('/open-path?path=' + encodeURIComponent(path))
33407        .then(function (r) { return r.json(); })
33408        .then(function (d) {
33409          if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
33410        })
33411        .catch(function () {});
33412    }
33413
33414    // \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
33415    // The server renders every row once; we lift them into a plain-data array and
33416    // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
33417    // filtering run on the array (no DOM churn) and each render rebuilds just one
33418    // page (~25 rows). This keeps every interaction O(page) instead of O(all
33419    // files): a 28k-row table previously re-touched every node on each click
33420    // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
33421    var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
33422
33423    function parseDeltaNum(str) {
33424      if (!str || str === '\u2014') return 0;
33425      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
33426    }
33427
33428    function captureDelta() {
33429      var tbody = document.getElementById('delta-tbody');
33430      if (!tbody) return;
33431      var rows = tbody.querySelectorAll('.delta-row');
33432      for (var i = 0; i < rows.length; i++) {
33433        var r = rows[i];
33434        DELTA.push({
33435          h: r.innerHTML,
33436          cls: r.className,
33437          path: r.getAttribute('data-path') || '',
33438          lang: r.getAttribute('data-language') || '',
33439          status: r.getAttribute('data-status') || '',
33440          bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
33441          cc: parseFloat(r.getAttribute('data-current-code')) || 0,
33442          cd: parseDeltaNum(r.getAttribute('data-code-delta')),
33443          cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
33444          td: parseDeltaNum(r.getAttribute('data-total-delta')),
33445          bcs: r.getAttribute('data-baseline-code') || '',
33446          ccs: r.getAttribute('data-current-code') || '',
33447          cds: r.getAttribute('data-code-delta') || '',
33448          cmds: r.getAttribute('data-comment-delta') || '',
33449          tds: r.getAttribute('data-total-delta') || ''
33450        });
33451      }
33452      tbody.innerHTML = '';
33453    }
33454
33455    function applyDeltaQuery() {
33456      var v = (activeStatusFilter === 'all') ? DELTA.slice()
33457        : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
33458      if (sortCol) {
33459        var asc = sortOrder === 'asc';
33460        v.sort(function(a, b) {
33461          var va, vb;
33462          if (sortCol === 'path') { va = a.path; vb = b.path; }
33463          else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
33464          else if (sortCol === 'status') { va = a.status; vb = b.status; }
33465          else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
33466          else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
33467          else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
33468          else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
33469          else { return 0; }
33470          if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
33471          return va < vb ? 1 : va > vb ? -1 : 0;
33472        });
33473      }
33474      _deltaView = v;
33475      deltaCurrPage = 1;
33476      renderDeltaPage();
33477    }
33478
33479    function renderDeltaPage() {
33480      var total = _deltaView.length;
33481      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
33482      if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
33483      if (deltaCurrPage < 1) deltaCurrPage = 1;
33484      var start = (deltaCurrPage - 1) * deltaPerPage;
33485      var end = Math.min(start + deltaPerPage, total);
33486      var tbody = document.getElementById('delta-tbody');
33487      if (tbody) {
33488        var html = '';
33489        for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
33490        tbody.innerHTML = html;
33491      }
33492      var rl = document.getElementById('pg-range-label');
33493      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
33494      var btns = document.getElementById('pg-btns');
33495      if (!btns) return;
33496      btns.innerHTML = '';
33497      if (totalPages <= 1) return;
33498      function makeBtn(lbl, pg, active, disabled) {
33499        var b = document.createElement('button');
33500        b.className = 'pg-btn' + (active ? ' active' : '');
33501        b.textContent = lbl; b.disabled = disabled;
33502        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
33503        return b;
33504      }
33505      btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
33506      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
33507      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
33508      btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
33509    }
33510
33511    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
33512
33513    function filterRows(status, btn) {
33514      activeStatusFilter = status;
33515      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
33516        b.classList.remove('active');
33517      });
33518      if (btn) btn.classList.add('active');
33519      applyDeltaQuery();
33520    }
33521
33522    // ── Sorting ──────────────────────────────────────────────────────────────
33523    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
33524    sortHeaders.forEach(function(th) {
33525      th.addEventListener('click', function(e) {
33526        if (e.target.classList.contains('col-resize-handle')) return;
33527        var col = th.dataset.sortCol;
33528        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
33529        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
33530        th.classList.add('sort-' + sortOrder);
33531        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
33532        applyDeltaQuery();
33533      });
33534    });
33535
33536    // ── Column resize ─────────────────────────────────────────────────────────
33537    (function() {
33538      var table = document.getElementById('delta-table');
33539      if (!table) return;
33540      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
33541      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
33542      ths.forEach(function(th, i) {
33543        var handle = th.querySelector('.col-resize-handle');
33544        if (!handle || !cols[i]) return;
33545        handle.addEventListener('mousedown', function(e) {
33546          e.stopPropagation(); e.preventDefault();
33547          // Lock every column to its current rendered px width and size the table
33548          // to the column total. With table-layout:fixed + width:100% the table is
33549          // pinned to the container, so widening one <col> only rebalances the rest
33550          // and the drag looks inert; pinning px widths lets the column actually
33551          // grow while the wrapper (overflow-x:auto) scrolls.
33552          var startTableW = 0;
33553          for (var k = 0; k < ths.length; k++) {
33554            if (!cols[k]) continue;
33555            var w = ths[k].getBoundingClientRect().width;
33556            cols[k].style.width = w + 'px';
33557            startTableW += w;
33558          }
33559          table.style.width = startTableW + 'px';
33560          var startX = e.clientX;
33561          var startW = ths[i].getBoundingClientRect().width;
33562          handle.classList.add('dragging');
33563          function onMove(ev) {
33564            var newW = Math.max(40, startW + ev.clientX - startX);
33565            cols[i].style.width = newW + 'px';
33566            table.style.width = (startTableW + (newW - startW)) + 'px';
33567          }
33568          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
33569          document.addEventListener('mousemove', onMove);
33570          document.addEventListener('mouseup', onUp);
33571        });
33572      });
33573    })();
33574
33575    // ── Reset ─────────────────────────────────────────────────────────────────
33576    window.resetDeltaTable = function() {
33577      sortCol = null; sortOrder = 'asc';
33578      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
33579      var table = document.getElementById('delta-table');
33580      if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
33581      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
33582      activeStatusFilter = 'all';
33583      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
33584      var allBtn = document.querySelector('.tab-btn');
33585      if (allBtn) allBtn.classList.add('active');
33586      applyDeltaQuery();
33587    };
33588
33589    // Compact number formatter (shared by the delta table; charts define their own locally)
33590    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();}
33591    function fmtFull(n){return Number(n).toLocaleString();}
33592
33593    // Format from-to numbers with fmt() and ensure zero→dash for added/removed
33594    function fmtFromTo() {
33595      var tbody = document.getElementById('delta-tbody');
33596      if (!tbody) return;
33597      tbody.querySelectorAll('.delta-row').forEach(function(row) {
33598        var status = row.dataset.status || '';
33599        var ft = row.querySelector('.from-to');
33600        if (!ft) return;
33601        var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
33602        var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
33603        var strongs = ft.querySelectorAll('strong');
33604        // Apply fmt() to non-absent strong values
33605        strongs.forEach(function(el) {
33606          var n = parseInt(el.textContent, 10);
33607          if (!isNaN(n)) el.textContent = fmtFull(n);
33608        });
33609        // Safety: force dash for genuinely absent sides
33610        if (status === 'added' && bv === 0) {
33611          var bs = ft.querySelector('strong:first-of-type');
33612          if (bs && bs.textContent === '0') {
33613            bs.outerHTML = '<span class="ft-absent">\u2014</span>';
33614          }
33615        }
33616        if (status === 'removed' && cv === 0) {
33617          var cs = ft.querySelector('strong:last-of-type');
33618          if (cs && cs.textContent === '0') {
33619            cs.outerHTML = '<span class="ft-absent">\u2014</span>';
33620          }
33621        }
33622      });
33623    }
33624    // Initialize: format the server-rendered rows, lift them into the data model
33625    // (which also clears the DOM), then render only the first page.
33626    fmtFromTo();
33627    captureDelta();
33628    applyDeltaQuery();
33629
33630    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
33631    (function() {
33632      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
33633        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
33634      });
33635      var resetBtn = document.getElementById('delta-reset-btn');
33636      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
33637      var csvBtn = document.getElementById('delta-csv-btn');
33638      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
33639      var xlsBtn = document.getElementById('delta-xls-btn');
33640      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
33641      // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
33642      function sdFetchUri(path) {
33643        return fetch(path).then(function(r){return r.blob();}).then(function(b){
33644          return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
33645        }).catch(function(){return '';});
33646      }
33647      function sdInlineImgs(html, cb) {
33648        var paths=[], seen={};
33649        html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
33650        if(!paths.length){cb(html);return;}
33651        Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
33652          .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
33653          .catch(function(){cb(html);});
33654      }
33655      function buildFullPageHtml(pdfMode) {
33656        if(pdfMode) document.body.classList.add('pdf-mode');
33657        var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
33658        renderDeltaPage();
33659        var html = window.sxSelfContain(document.documentElement.outerHTML);
33660        deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
33661        if(pdfMode) document.body.classList.remove('pdf-mode');
33662        return html;
33663      }
33664      var chartsBtn = document.getElementById('delta-charts-btn');
33665      if (chartsBtn) chartsBtn.addEventListener('click', function() {
33666        var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
33667        sdInlineImgs(buildFullPageHtml(false), function(html) {
33668          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
33669          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
33670          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
33671          btn.disabled=false;btn.innerHTML=orig;
33672        });
33673      });
33674      var pageHtmlBtn = document.getElementById('page-export-html-btn');
33675      if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
33676        var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
33677        sdInlineImgs(buildFullPageHtml(false), function(html) {
33678          var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
33679          var a=document.createElement('a');a.href=URL.createObjectURL(blob);
33680          a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
33681          btn.disabled=false;btn.innerHTML=orig;
33682        });
33683      });
33684      // PDF export — clean document-style report, not a web page screenshot
33685      function buildDeltaPdfHtml() {
33686        var sd=_sd, dr=getDeltaExportRows();
33687        var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
33688        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+'%';}
33689        function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
33690        var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
33691        var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
33692        var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
33693        var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
33694        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
33695        function fmtN(n){return Number(n).toLocaleString();}
33696        function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
33697        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 class="sx-4e307fd5" >'+esc(s)+'</span>':'<span class="sx-e46b3d3d" >'+esc(s)+'</span>';}
33698        var lm={};
33699        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;});
33700        var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
33701        var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
33702        // The header/footer flow in normal document order (NOT position:fixed).
33703        // A fixed header repeats on every printed page in Chromium and overlaps
33704        // the content beneath it — silently swallowing the first few table rows of
33705        // pages 2+ and clipping the summary cards on page 1. Letting the header
33706        // flow once at the top and relying on the table's <thead> (which Chromium
33707        // repeats per page) keeps every row visible. `.body` keeps a small inset
33708        // so nothing bleeds to the sheet edge.
33709        var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
33710          '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
33711          '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
33712          '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
33713          '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
33714          '.ph-brand em{color:#c45c10;font-style:normal;}'+
33715          '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
33716          '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
33717          '.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;}'+
33718          '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
33719          '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
33720          '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
33721          '.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;}'+
33722          '.body{padding:12px 18px 0;}'+
33723          '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
33724          '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
33725          '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
33726          '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
33727          '.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;}'+
33728          '.meta>div{flex:1 1 0;}'+
33729          '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
33730          '.sec{margin-bottom:10px;}'+
33731          '.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;}'+
33732          '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
33733          '.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;}'+
33734          '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
33735          '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
33736          'table{width:100%;border-collapse:collapse;font-size:12px;}'+
33737          '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;}'+
33738          'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
33739          'tr:nth-child(even) td{background:#faf8f6;}'+
33740          '.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;}'+
33741          '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
33742          '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
33743          '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
33744          '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
33745          '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
33746          '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
33747          '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
33748          '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
33749          '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
33750          '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
33751          '.fcc-n{font-size:18px;font-weight:900;}'+
33752          '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
33753        var fileRows=dchg.map(function(r){
33754          var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
33755          return '<tr><td class="sx-a83ce3a2" >'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
33756            '<td style="'+ss+'">'+esc(st)+'</td>'+
33757            '<td class="sx-5f326564" >'+fmtN(r[3])+'</td>'+
33758            '<td class="sx-5f326564" >'+fmtN(r[4])+'</td>'+
33759            '<td class="sx-5f326564" >'+delt(r[5])+'</td></tr>';
33760        }).join('')||'<tr><td class="sx-1daf8e4d" colspan="6" >No file changes between these scans.</td></tr>';
33761        var more='';
33762        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 class="sx-5f326564" >'+fmtN(e.f)+'</td><td class="sx-5f326564" >'+fmtN(e.c)+'</td><td class="sx-5f326564" >'+delt(dv)+'</td></tr>';}).join('');
33763        var extraCards='';
33764        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>';}
33765        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>';}
33766        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
33767          '<div class="pdf-header">'+
33768          '<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>'+
33769          '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
33770          '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
33771          '</div>'+
33772          '<div class="body">'+
33773          '<div class="sec"><p class="sh">Summary Metrics</p>'+
33774          '<div class="msec">'+
33775          '<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>'+
33776          '<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>'+
33777          '<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>'+
33778          '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v sx-24025da0" >+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
33779          '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v sx-fe2ed38d" >−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
33780          '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v sx-45e37f19" >'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
33781          extraCards+'</div></div>'+
33782          '<div class="sec"><p class="sh">File Changes</p>'+
33783          '<div class="fcsec">'+
33784          '<div class="fcc"><span class="fcc-n sx-8b3f8a70" >'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
33785          '<div class="fcc"><span class="fcc-n sx-24025da0" >'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
33786          '<div class="fcc"><span class="fcc-n sx-fe2ed38d" >'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
33787          '<div class="fcc"><span class="fcc-n sx-e5529f3c" >'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
33788          '<div class="fcc"><span class="fcc-n sx-45e37f19" >'+fullN(Number(sd.fm)+Number(sd.fa)+Number(sd.fr)+Number(sd.fu))+'</span><span class="fcc-l">Total (modified + added + removed + unchanged)</span></div>'+
33789          '</div></div>'+
33790          (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th class="sx-5f326564" >Files</th><th class="sx-5f326564" >Code Lines</th><th class="sx-5f326564" >Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
33791          '<div class="sec">'+
33792          '<table><thead>'+
33793          '<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>'+
33794          '<tr><th>File</th><th>Language</th><th>Status</th>'+
33795          '<th class="sx-5f326564" >Code Before</th><th class="sx-5f326564" >Code After</th><th class="sx-5f326564" >Code \u0394</th>'+
33796          '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
33797          '</div>'+
33798          '<div class="rfoot">'+
33799          '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
33800          '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
33801          '</div>'+
33802          '</body></html>';
33803      }
33804      function doDeltaPdf(btn) {
33805        window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
33806      }
33807      var pdfBtn = document.getElementById('delta-pdf-btn');
33808      if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
33809      var pagePdfBtn = document.getElementById('page-export-pdf-btn');
33810      if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
33811      if (location.protocol === 'file:') {
33812        [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'; } });
33813        [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'; } });
33814      }
33815      var ppSel = document.getElementById('per-page-sel');
33816      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
33817      var pathLink = document.getElementById('project-path-link');
33818      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
33819    })();
33820
33821    // ── Export helpers ────────────────────────────────────────────────────────
33822    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
33823    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
33824    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);}
33825    function slocMakeXlsx(fname,sd,dr){
33826      var enc=new TextEncoder();
33827      // CRC-32 table
33828      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;}
33829      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;}
33830      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
33831      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
33832      // Shared string table
33833      var ss=[],si={};
33834      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
33835      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
33836      // Worksheet builder — each WS() call gets its own row counter R
33837      function WS(){
33838        var R=0,buf=[];
33839        function cl(c){return String.fromCharCode(65+c);}
33840        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
33841          '<v>'+S(v)+'</v></c>';}
33842        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
33843          (st?' s="'+st+'"':'')+'>'+
33844          '<v>'+(+v)+'</v></c>';}
33845        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
33846        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
33847          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
33848          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
33849          '<sheetFormatPr defaultRowHeight="15"/>'+
33850          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
33851        return{sc:sc,nc:nc,row:row,xml:xml};
33852      }
33853      // Language breakdown
33854      var lm={};
33855      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;});
33856      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
33857      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
33858      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
33859      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
33860      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
33861      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
33862      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):'';}
33863      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
33864      // Summary sheet
33865      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
33866      r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
33867      r1(s1(0,proj,2));
33868      r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
33869      r1('');
33870      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
33871      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))));
33872      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))));
33873      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))));
33874      r1('');
33875      r1(s1(0,'FILE CHANGES',8));
33876      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
33877      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
33878      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
33879      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
33880      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
33881      r1(s1(0,'Total')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm+sd.fa+sd.fr+sd.fu,4)+s1(4,_tp(sd.fm+sd.fa+sd.fr+sd.fu)));
33882      if(langs.length){
33883        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
33884        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
33885        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)));});
33886      }
33887      r1('');r1(s1(0,'SCAN METADATA',8));
33888      r1(s1(1,_blabel)+s1(2,_clabel));
33889      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
33890      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
33891      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"/>');
33892      // File Delta sheet
33893      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
33894      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));
33895      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)));});
33896      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
33897      // Shared strings XML
33898      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
33899        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
33900        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
33901      // XLSX file map
33902      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
33903      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>',
33904        '_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>',
33905        '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>',
33906        '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>',
33907        '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>',
33908        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
33909      // ZIP packer — STORED (no compression), compatible with all XLSX readers
33910      var zparts=[],zcds=[],zoff=0,znf=0;
33911      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
33912       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
33913      ].forEach(function(name){
33914        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
33915        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]);
33916        var entry=new Uint8Array(lha.length+nb.length+sz);
33917        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
33918        zparts.push(entry);
33919        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));
33920        var cde=new Uint8Array(cda.length+nb.length);
33921        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
33922        zcds.push(cde);zoff+=entry.length;znf++;
33923      });
33924      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
33925      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]);
33926      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
33927      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
33928      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
33929      zout.set(new Uint8Array(ea),zpos);
33930      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
33931      var xurl=URL.createObjectURL(xblob);
33932      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
33933      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
33934      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
33935    }
33936    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;');}
33937    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
33938    function getExportFilename(ext){return _exportBase+'.'+ext;}
33939
33940    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 }}'};
33941    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;}
33942    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
33943    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
33944    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
33945    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
33946    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):'';}
33947    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
33948    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)]];}
33949    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
33950    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)];});}
33951    window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
33952    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
33953
33954    // ── Chart HTML report ─────────────────────────────────────────────────────
33955    function slocChartReport(fname, sd, dr) {
33956      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
33957      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
33958      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
33959      function fmt(n){return Number(n).toLocaleString();}
33960      function px(n){return Math.round(n);}
33961      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
33962      // Language map
33963      var lm={};
33964      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;});
33965      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
33966
33967      // Builds onmouse* attrs for interactive tooltip on each SVG element
33968      function barTT(label,val){
33969        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
33970      }
33971
33972      // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
33973      //    match the Language Code Delta column height) ────────────
33974      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'}];
33975      var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
33976      var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
33977      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
33978      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
33979      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"/>';}
33980      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
33981      c1mets.forEach(function(m,i){
33982        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
33983        // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
33984        var gMax=Math.max(m.b,m.c)*1.15||1;
33985        var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
33986        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>';
33987        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))+'/>';
33988        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>';
33989        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))+'/>';
33990        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>';
33991        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>';
33992        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>';
33993      });
33994      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>';
33995      c1+='</svg>';
33996
33997      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
33998      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'}];
33999      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
34000      var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
34001      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
34002      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34003      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34004      mets.forEach(function(m,i){
34005        var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
34006        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
34007        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
34008        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>';
34009        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
34010        if(bw>=52){
34011          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>';
34012        }else{
34013          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
34014          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>';
34015        }
34016      });
34017      c2+='</svg>';
34018
34019      // ── Chart 3: Language Code Delta ─────────────────────────────────────
34020      var c3='';
34021      if(langs.length){
34022        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
34023        var C3W=550,c3LW=124,c3FW=52;
34024        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
34025        var L3rH=30,C3H=langs.length*L3rH+20;
34026        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34027        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34028        langs.forEach(function(l,i){
34029          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
34030          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
34031          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
34032          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>';
34033          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':''))+'/>';
34034          if(bw>=48){
34035            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>';
34036          }else{
34037            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
34038            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>';
34039          }
34040          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>';
34041        });
34042        c3+='</svg>';
34043      }
34044
34045      // ── Chart 4: File Change Donut — centered pie with legend below
34046      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;});
34047      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
34048      var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
34049      var c4='<svg class="sx-f5cb162e" viewBox="0 0 '+C4W+' '+C4H+'" width="100%"  xmlns="http://www.w3.org/2000/svg">';
34050      var ang=-Math.PI/2;
34051      segs.forEach(function(s){
34052        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
34053        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
34054        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
34055        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
34056        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
34057        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)+'%')+'/>';
34058        ang+=sw;
34059      });
34060      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>';
34061      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>';
34062      segs.forEach(function(s,i){
34063        var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
34064        c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
34065        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>';
34066      });
34067      c4+='</svg>';
34068
34069      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
34070      var ttJs='var tt=document.getElementById("ox-tt");'+
34071        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
34072        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
34073        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
34074        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
34075        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
34076        'function oxHT(){tt.style.display="none";}';
34077
34078      // body max-width keeps charts from inflating beyond design dimensions on
34079      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
34080      // each chart's height blows up proportionally, breaking the one-page layout.
34081      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;}'+
34082        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
34083        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
34084        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
34085        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
34086        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
34087        'svg{display:block;}'+
34088        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
34089        '#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;}'+
34090        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
34091      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
34092        '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
34093        '<div id="ox-tt"><\/div>'+
34094        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
34095        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
34096        '<div class="two-col">'+
34097        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
34098        '<div class="leg">'+
34099        '<span><span class="dot sx-618fd811" ><\/span><span class="sx-d50d9131" >Code Lines<\/span><\/span>'+
34100        '<span><span class="dot sx-d94e9768" ><\/span><span class="sx-f6800712" >Files<\/span><\/span>'+
34101        '<span><span class="dot sx-38f87134" ><\/span><span class="sx-c64494ae" >Comments<\/span><\/span>'+
34102        '<span class="sx-d1cc41e0" >&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
34103        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
34104        '<\/div>'+
34105        '<div class="two-col">'+
34106        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
34107        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
34108        '<\/div>'+
34109        '<script>'+ttJs+'<\/script>'+
34110        '<\/body><\/html>';
34111      slocDownload(html, fname, 'text/html;charset=utf-8;');
34112    }
34113    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
34114    window.buildDeltaChartsHtml = function() {
34115      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
34116      var sd=_sd;
34117      var projEl=document.querySelector('[data-folder]');
34118      var proj=projEl?projEl.getAttribute('data-folder'):'';
34119      var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
34120      var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
34121      var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
34122      var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
34123      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";}';
34124      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);}';
34125      return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
34126        '<div id="ox-tt"><\/div>'+
34127        '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
34128        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
34129        '<div class="two-col">'+
34130        '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
34131        '<div class="leg"><span><span class="dot sx-618fd811" ><\/span><span class="sx-d50d9131" >Code Lines<\/span><\/span>'+
34132        '<span><span class="dot sx-d94e9768" ><\/span><span class="sx-f6800712" >Files<\/span><\/span>'+
34133        '<span><span class="dot sx-38f87134" ><\/span><span class="sx-c64494ae" >Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
34134        (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
34135        '<\/div>'+
34136        '<div class="two-col">'+
34137        '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
34138        '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
34139        '<\/div>'+
34140        '<script>'+ttJs+'<\/script>'+
34141        '<\/body><\/html>';
34142    };
34143    // ── Inline delta charts ────────────────────────────────────────────────────
34144    var _icTT=document.getElementById('ic-tt');
34145    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
34146    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';};
34147    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
34148    window.addEventListener('blur',function(){window.icHT();});
34149    document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
34150    (function(){
34151      // Theme-aware palette — matches the canonical scheme used by /test-metrics
34152      // charts so every page renders bars/text/grid with the same colours and
34153      // adapts to dark mode (see Design section in CLAUDE.md).
34154      var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
34155      function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
34156      var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
34157      // Deeper shade of each metric hue for "before"/baseline bars — bold (not
34158      // washed) so the chart reads with the same weight as /test-metrics.
34159      var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
34160      var FADE=dark?'#524238':'#e6d0bf';
34161      var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
34162      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
34163      function fmt(n){return Number(n).toLocaleString();}
34164      function px(n){return Math.round(n);}
34165      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
34166      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
34167      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);});}
34168      var dr=getDeltaExportRows(),sd=_sd,lm={};
34169      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;});
34170      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
34171      // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
34172      // the bars are as tall as the (usually taller) Language Code Delta sibling that
34173      // shares the same grid row, instead of sitting short at the top.
34174      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}];
34175      function drawC1(){
34176        var C1W=600,C1H=188;
34177        var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
34178        if(host&&card&&host.clientWidth>0){
34179          var avW=host.clientWidth;
34180          var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
34181          var wantH=availPx*C1W/avW;
34182          if(wantH>C1H)C1H=wantH;
34183        }
34184        var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
34185        var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34186        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"/>';}
34187        c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
34188        c1mets.forEach(function(m,i){
34189          var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
34190          // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
34191          // code lines vs 28K files) are all readable — a shared scale buries the small ones.
34192          var gMax=Math.max(m.b,m.c)*1.15||1;
34193          var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
34194          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>';
34195          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"/>';
34196          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>';
34197          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"/>';
34198          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>';
34199          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>';
34200          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>';
34201        });
34202        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>';
34203        c1+='</svg>';
34204        return c1;
34205      }
34206      var c1=drawC1();
34207      // Chart 2: Delta by Metric
34208      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}];
34209      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
34210      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;
34211      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34212      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34213      mets.forEach(function(m,i){
34214        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);
34215        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>';
34216        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
34217        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>';}
34218        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>';}
34219      });
34220      c2+='</svg>';
34221      // Chart 3: Language Code Delta
34222      var c3='';
34223      if(langs.length){
34224        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
34225        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;
34226        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34227        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34228        langs.forEach(function(l,i){
34229          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);
34230          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>';
34231          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"/>';
34232          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>';}
34233          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>';}
34234          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>';
34235        });
34236        c3+='</svg>';
34237      }
34238      // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
34239      var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
34240      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;});
34241      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
34242      var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
34243      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);
34244      var c4='<svg class="sx-1f581618" viewBox="0 0 '+DW+' '+DH+'" width="100%"  xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
34245      if(segs.length===1){
34246        var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
34247        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+'"/>';
34248      } else {
34249        // Give every visible slice a small minimum sweep, taken from the largest
34250        // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
34251        // arc whose start and end points coincide, so SVG renders nothing (blank).
34252        var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
34253        for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
34254        var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
34255        sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
34256        segs.forEach(function(s,si){
34257          var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
34258          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);
34259          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);
34260          var pct=Math.round(s.v/tot*100);
34261          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"/>';
34262          if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text class="sx-c3270469" 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')+'" >'+pct+'%</text>';}
34263          ang+=sw;
34264        });
34265      }
34266      c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
34267      c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
34268      segs.forEach(function(s,i){
34269        var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
34270        c4+='<g class="sx-83ac1cee"'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' >';
34271        c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
34272        c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
34273        c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
34274        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>';
34275        c4+='</g>';
34276      });
34277      c4+='</svg>';
34278      // Inject the fixed-height siblings first so the grid row settles to the (taller)
34279      // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
34280      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
34281      var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p class="sx-90171b6d" >No language delta.</p>';addTT(e3);}
34282      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
34283      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
34284      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
34285
34286      // Compare Timeline chart (Baseline vs Current, 2 points)
34287      (function() {
34288        var activeCmpMetric='code';
34289        var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
34290        function renderCmpTL(metric, targetSvg, targetH) {
34291          var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
34292          var W=svg.getBoundingClientRect().width||800,H=targetH||280;
34293          svg.setAttribute('height',H);
34294          var pad={l:62,r:20,t:32,b:72};
34295          var dark=document.body.classList.contains('dark-theme');
34296          var cmpPts=[
34297            {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
34298            {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
34299          ];
34300          var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
34301          var valid=pts.filter(function(v){return v!=null;});
34302          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;}
34303          var minV=0,maxV=Math.max.apply(null,valid);
34304          if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
34305          var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
34306          var cx0=pad.l,cx1=pad.l+plotW;
34307          var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
34308          var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
34309          var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
34310          var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
34311          var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
34312          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();}
34313          function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
34314          var parts=[];
34315          parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
34316          for(var gi=0;gi<5;gi++){
34317            var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
34318            parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
34319            parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
34320          }
34321          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+'"/>');
34322          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"/>');
34323          var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
34324                      {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
34325          dotPts.forEach(function(pt){
34326            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>');
34327            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"/>');
34328            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>');
34329            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>');
34330          });
34331          parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
34332          svg.setAttribute('viewBox','0 0 '+W+' '+H);
34333          svg.innerHTML=parts.join('');
34334          // Hover: crosshair + tooltip (matches multi-scan timeline)
34335          var cmpTT=document.getElementById('ic-tt');
34336          svg.onmousemove=function(e){
34337            var rect=svg.getBoundingClientRect();
34338            var scaleX=W/rect.width;
34339            var mouseX=(e.clientX-rect.left)*scaleX;
34340            var nearest=-1,minDist=Infinity;
34341            var cxArr=[cx0,cx1];
34342            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;}}
34343            if(nearest<0)return;
34344            var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
34345            var xhair=svg.querySelector('.cmp-xhair');
34346            if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
34347            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"/>';
34348            if(!cmpTT)return;
34349            var clbl=cmpPts[nearest].label;
34350            var scanLbl=nearest===0?'Baseline':'Current';
34351            cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span class="sx-0819fd61" >'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
34352            var bx=rect.left+(nc/W*rect.width)+18;
34353            if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
34354            cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
34355          };
34356          svg.onmouseleave=function(){
34357            var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
34358            if(cmpTT)cmpTT.style.display='none';
34359          };
34360        }
34361        document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
34362          btn.addEventListener('click',function(){
34363            activeCmpMetric=this.dataset.cmpMetric;
34364            document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
34365            this.classList.add('active');
34366            renderCmpTL(activeCmpMetric);
34367          });
34368        });
34369        var ttgl=document.getElementById('theme-toggle');
34370        if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
34371        if(typeof ResizeObserver!=='undefined'){
34372          var cmpSvg=document.getElementById('cmp-tl-svg');
34373          if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
34374        }
34375        // Expose the timeline renderer + current metric so the Full View modal can
34376        // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
34377        window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
34378        window.__sdGetMetric=function(){return activeCmpMetric;};
34379        renderCmpTL(activeCmpMetric);
34380      })();
34381
34382      // HTML legend hover -> highlight matching SVG bars within the SAME card only
34383      document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
34384        var metric=leg.getAttribute('data-highlight');
34385        var parentCard=leg.closest('.ic-card');
34386        var chartEl=parentCard?parentCard.querySelector('[id]'):null;
34387        if(!chartEl)return;
34388        leg.addEventListener('mouseenter',function(){
34389          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
34390            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';}
34391            else{x.style.opacity='0.28';}
34392          });
34393        });
34394        leg.addEventListener('mouseleave',function(){
34395          chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
34396        });
34397      });
34398
34399      // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
34400      (function(){
34401        var ov=document.getElementById('ic-svg-modal-ov');
34402        var body=document.getElementById('ic-svg-modal-body');
34403        var ttl=document.getElementById('ic-svg-modal-title');
34404        var closeBtn=document.getElementById('ic-svg-modal-close');
34405        if(!ov||!body)return;
34406        function close(){
34407          ov.classList.remove('open');body.innerHTML='';
34408          if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
34409          var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
34410        }
34411        function open(srcId,title){
34412          var src=document.getElementById(srcId);if(!src)return;
34413          if(ttl)ttl.textContent=title||'';
34414          // The Timeline is pixel-sized (viewBox locked to its render width), so a static
34415          // snapshot stretches and loses interactivity. Re-render it live into the modal at
34416          // full size instead — keeps proportions, animation, crosshair, tooltip and the
34417          // metric tabs working exactly like the inline chart.
34418          if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
34419            var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
34420            var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
34421            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('');
34422            body.innerHTML='<div class="cmp-tl-btns sx-e1b4305c" >'+btnsHtml+'</div><div class="chart-wrap sx-9ccc4ca9" ><svg class="sx-e3ce48e7" id="cmp-tl-fv-svg" width="100%" height="440" ></svg></div>';
34423            var fvSvg=body.querySelector('#cmp-tl-fv-svg');
34424            window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
34425            ov.classList.add('open');
34426            requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
34427            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;}
34428            body.querySelectorAll('[data-fv-metric]').forEach(function(b){
34429              b.addEventListener('click',function(){
34430                if(!window.__sdFvTL)return;
34431                window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
34432                body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
34433                this.classList.add('active');
34434                window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
34435              });
34436            });
34437            return;
34438          }
34439          var card=src.closest('.ic-card');
34440          var legHtml='';
34441          if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg sx-16dbf2a3" >'+leg.innerHTML+'</div>';}
34442          var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
34443          if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p class="sx-90171b6d" >No chart data to display.</p>';ov.classList.add('open');return;}
34444          body.innerHTML=legHtml+inner;
34445          var svg=body.querySelector('svg');
34446          if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
34447          addTT(body);
34448          ov.classList.add('open');
34449        }
34450        document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
34451          btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
34452        });
34453        if(closeBtn)closeBtn.addEventListener('click',close);
34454        ov.addEventListener('click',function(e){if(e.target===ov)close();});
34455        document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
34456      })();
34457
34458      document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
34459    })();
34460  </script>
34461  {{ toast_assets|safe }}
34462  <script nonce="{{ csp_nonce }}">
34463  (function(){
34464    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'}];
34465    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);});}
34466    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
34467    function init(){
34468      var btn=document.getElementById('settings-btn');if(!btn)return;
34469      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
34470      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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
34471      document.body.appendChild(m);
34472      var g=document.getElementById('scheme-grid');
34473      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);});
34474      var cl=document.getElementById('settings-close');
34475      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);});})();
34476      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');});
34477      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
34478      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
34479    }
34480    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
34481  }());
34482  </script>
34483  <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]';
34484  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;}
34485  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>
34486</body>
34487</html>
34488"##,
34489    ext = "html"
34490)]
34491// Template structs need many bool fields to pass Askama rendering flags.
34492#[allow(clippy::struct_excessive_bools)]
34493struct CompareTemplate {
34494    /// Pre-rendered branded loading overlay + visibility gate (see `loading_overlay_block`).
34495    loading_overlay: String,
34496    version: &'static str,
34497    project_label: String,
34498    baseline_git_commit: String,
34499    current_git_commit: String,
34500    baseline_run_id: String,
34501    current_run_id: String,
34502    baseline_run_id_short: String,
34503    current_run_id_short: String,
34504    baseline_timestamp: String,
34505    baseline_timestamp_utc_ms: i64,
34506    current_timestamp: String,
34507    current_timestamp_utc_ms: i64,
34508    project_path: String,
34509    baseline_code: u64,
34510    current_code: u64,
34511    code_lines_delta_str: String,
34512    code_lines_delta_class: String,
34513    baseline_files: u64,
34514    current_files: u64,
34515    files_analyzed_delta_str: String,
34516    files_analyzed_delta_class: String,
34517    baseline_comments: u64,
34518    current_comments: u64,
34519    comment_lines_delta_str: String,
34520    comment_lines_delta_class: String,
34521    baseline_code_fmt: String,
34522    current_code_fmt: String,
34523    baseline_files_fmt: String,
34524    current_files_fmt: String,
34525    baseline_comments_fmt: String,
34526    current_comments_fmt: String,
34527    code_lines_pct_str: String,
34528    files_analyzed_pct_str: String,
34529    comment_lines_pct_str: String,
34530    code_lines_added: i64,
34531    code_lines_removed: i64,
34532    /// Code lines residing in files modified between the two scans (current-scan counts).
34533    code_lines_modified: i64,
34534    /// Code lines residing in files identical between the two scans.
34535    code_lines_unmodified: i64,
34536    /// Sum of added + removed + modified + unmodified code-line metrics.
34537    code_lines_total: i64,
34538    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
34539    new_scope: bool,
34540    churn_rate_str: String,
34541    churn_rate_class: String,
34542    scope_flag: bool,
34543    files_added: usize,
34544    files_removed: usize,
34545    files_modified: usize,
34546    files_unchanged: usize,
34547    files_total: usize,
34548    file_rows: Vec<CompareFileDeltaRow>,
34549    baseline_git_author: Option<String>,
34550    current_git_author: Option<String>,
34551    baseline_git_branch: String,
34552    current_git_branch: String,
34553    baseline_performed_by: String,
34554    current_performed_by: String,
34555    baseline_git_tags: Option<String>,
34556    current_git_tags: Option<String>,
34557    baseline_git_commit_date: Option<String>,
34558    current_git_commit_date: Option<String>,
34559    project_name: String,
34560    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
34561    submodule_options: Vec<String>,
34562    /// True when either run has submodule data — controls whether the scope bar is shown.
34563    has_any_submodule_data: bool,
34564    /// The submodule currently being compared, if the `sub` query param was provided.
34565    active_submodule: Option<String>,
34566    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
34567    super_scope_active: bool,
34568    csp_nonce: String,
34569    /// Shared toast + PDF-export helper block (see `sloc_toast_assets`).
34570    toast_assets: String,
34571    /// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
34572    coverage_delta_card: String,
34573    baseline_test_count: u64,
34574    current_test_count: u64,
34575    baseline_coverage_pct: Option<f64>,
34576    current_coverage_pct: Option<f64>,
34577}
34578
34579// ── LoginTemplate ──────────────────────────────────────────────────────────────
34580
34581#[derive(Template)]
34582#[template(
34583    source = r##"
34584<!doctype html>
34585<html lang="en">
34586<head>
34587  <meta charset="utf-8">
34588  <meta name="viewport" content="width=device-width, initial-scale=1">
34589  <title>OxideSLOC | Sign In</title>
34590  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
34591  <link rel="stylesheet" href="/static/app.css">
34592  <script src="/static/app.js"></script>
34593  <style nonce="{{ csp_nonce }}">
34594    :root {
34595      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
34596      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
34597      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
34598      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
34599    }
34600    *{box-sizing:border-box;}
34601    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);}
34602    .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);}
34603    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
34604    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
34605    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
34606    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34607    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
34608    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34609    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
34610    @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));}}
34611    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
34612    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
34613    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
34614    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
34615    .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;}
34616    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
34617    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;}
34618    input[type=password]:focus{border-color:var(--oxide);}
34619    .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;}
34620    .btn:hover{opacity:.88;}
34621    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
34622    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
34623  </style>
34624</head>
34625<body>
34626  <div class="background-watermarks" aria-hidden="true">
34627    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34628    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34629    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34630    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34631    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34632    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34633    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34634  </div>
34635  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
34636<nav class="top-nav">
34637  <a class="brand" href="/">
34638    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
34639    <span class="brand-title">OxideSLOC</span>
34640  </a>
34641</nav>
34642<main class="page">
34643  <div class="card">
34644    <h1>Sign In</h1>
34645    <p class="subtitle">Enter the API key printed when the server started.</p>
34646    {% if has_error %}
34647    <div class="error">Incorrect API key — please try again.</div>
34648    {% endif %}
34649    <form method="POST" action="/auth/login">
34650      <input type="hidden" name="next" value="{{ next_url|e }}">
34651      <label for="key">API Key</label>
34652      <input id="key" type="password" name="key" autocomplete="current-password"
34653             placeholder="Paste your API key here" autofocus>
34654      <button type="submit" class="btn">Sign In</button>
34655    </form>
34656    <p class="hint">
34657      The API key was printed in the terminal when the server started.<br>
34658      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
34659      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
34660    </p>
34661  </div>
34662</main>
34663<script nonce="{{ csp_nonce }}">
34664(function() {
34665  (function randomizeWatermarks() {
34666    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
34667    if (!wms.length) return;
34668    var placed = [];
34669    function tooClose(top, left) {
34670      for (var i = 0; i < placed.length; i++) {
34671        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
34672        if (dt < 16 && dl < 12) return true;
34673      }
34674      return false;
34675    }
34676    function pick(leftBand) {
34677      for (var attempt = 0; attempt < 50; attempt++) {
34678        var top = Math.random() * 88 + 2;
34679        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
34680        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
34681      }
34682      var top = Math.random() * 88 + 2;
34683      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
34684      placed.push([top, left]); return [top, left];
34685    }
34686    var half = Math.floor(wms.length / 2);
34687    wms.forEach(function (img, i) {
34688      var pos = pick(i < half);
34689      var size = Math.floor(Math.random() * 100 + 120);
34690      var rot = (Math.random() * 360).toFixed(1);
34691      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
34692      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;
34693    });
34694  })();
34695  (function spawnCodeParticles() {
34696    var container = document.getElementById('code-particles');
34697    if (!container) return;
34698    var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
34699    var count = 44;
34700    for (var i = 0; i < count; i++) {
34701      (function(idx) {
34702        var el = document.createElement('span');
34703        el.className = 'code-particle';
34704        el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
34705        var left = Math.random() * 94 + 2;
34706        var top = Math.random() * 88 + 6;
34707        var dur = (Math.random() * 10 + 9).toFixed(1);
34708        var delay = (Math.random() * 18).toFixed(1);
34709        var rot = (Math.random() * 26 - 13).toFixed(1);
34710        var op = (Math.random() * 0.108 + 0.072).toFixed(3);
34711        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';
34712        container.appendChild(el);
34713      })(i);
34714    }
34715  })();
34716})();
34717</script>
34718</body>
34719</html>
34720"##,
34721    ext = "html"
34722)]
34723pub(crate) struct LoginTemplate {
34724    pub(crate) csp_nonce: String,
34725    pub(crate) has_error: bool,
34726    pub(crate) next_url: String,
34727    pub(crate) lockout_threshold: u32,
34728}
34729
34730// ── REST API reference page ────────────────────────────────────────────────────
34731
34732#[derive(Template)]
34733#[template(
34734    source = r##"
34735<!doctype html>
34736<html lang="en">
34737<head>
34738  <meta charset="utf-8">
34739  <meta name="viewport" content="width=device-width, initial-scale=1">
34740  <title>OxideSLOC — REST API Reference</title>
34741  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
34742  <link rel="stylesheet" href="/static/app.css">
34743  <script src="/static/app.js"></script>
34744  <style nonce="{{ csp_nonce }}">
34745    :root {
34746      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
34747      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
34748      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
34749      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
34750      --success:#16a34a;
34751    }
34752    body.dark-theme {
34753      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
34754      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
34755    }
34756    *{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;}
34757    .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);}
34758    .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;}
34759    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
34760    .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));}
34761    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
34762    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
34763    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
34764    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
34765    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
34766    @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; } }
34767    .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;}
34768    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
34769    .nav-pill.active{background:rgba(255,255,255,0.22);}
34770    .nav-dropdown{position:relative;display:inline-flex;}
34771    .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;}
34772    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
34773    .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;}
34774    .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;}
34775    .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);}
34776    .nav-dropdown-menu a:last-child{border-bottom:none;}
34777    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
34778    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
34779    .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;}
34780    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
34781    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
34782    .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;}
34783    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
34784    .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);}
34785    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
34786    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
34787    .settings-modal-body{padding:14px 16px 16px;}
34788    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
34789    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
34790    .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;}
34791    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
34792    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
34793    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
34794    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
34795    .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;}
34796    .tz-select:focus{border-color:var(--oxide);}
34797    .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
34798    .page-header{margin-bottom:28px;}
34799    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
34800    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
34801    .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;}
34802    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
34803    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
34804    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
34805    .callout strong{font-weight:800;}
34806    .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;}
34807    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
34808    .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;}
34809    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
34810    .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;}
34811    body.dark-theme .base-url-value{color:var(--accent);}
34812    .section{margin-bottom:36px;}
34813    .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);}
34814    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
34815    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
34816    .ep-header:hover{background:var(--surface-2);}
34817    .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;}
34818    .method.get{background:#dcfce7;color:#166534;}
34819    .method.post{background:#dbeafe;color:#1e40af;}
34820    .method.delete{background:#fee2e2;color:#991b1b;}
34821    body.dark-theme .method.get{background:#14532d;color:#86efac;}
34822    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
34823    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
34824    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
34825    .ep-path .param{color:var(--oxide-2);}
34826    body.dark-theme .ep-path .param{color:var(--oxide);}
34827    .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;}
34828    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
34829    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
34830    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
34831    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
34832    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
34833    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
34834    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
34835    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
34836    .ep-card.open .chevron{transform:rotate(180deg);}
34837    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
34838    .ep-card.open .ep-body{display:block;}
34839    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
34840    .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;}
34841    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
34842    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
34843    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
34844    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
34845    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);}
34846    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
34847    table.params tr:last-child td{border-bottom:none;}
34848    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
34849    .pt-type{color:var(--muted-2);font-size:12px;}
34850    .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;}
34851    .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;}
34852    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
34853    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
34854    details.schema{margin-bottom:14px;}
34855    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;}
34856    details.schema summary:hover{color:var(--text);}
34857    .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;}
34858    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
34859    .curl-wrap{position:relative;}
34860    .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;}
34861    .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;}
34862    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
34863    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
34864    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
34865    .webhook-note a{color:var(--accent-2);text-decoration:none;}
34866    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34867    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
34868    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34869    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
34870    @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));}}
34871    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
34872    .site-footer a{color:var(--muted);}
34873  </style>
34874</head>
34875<body>
34876  <div class="background-watermarks" aria-hidden="true">
34877    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34878    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34879    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34880    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34881    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34882    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34883    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34884  </div>
34885  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
34886  <div class="top-nav">
34887    <div class="top-nav-inner">
34888      <a class="brand" href="/">
34889        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
34890        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
34891      </a>
34892      <div class="nav-right">
34893        <a class="nav-pill" href="/">Home</a>
34894        <div class="nav-dropdown">
34895          <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>
34896          <div class="nav-dropdown-menu">
34897            <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>
34898          </div>
34899        </div>
34900        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
34901        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
34902        <div class="nav-dropdown">
34903          <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>
34904          <div class="nav-dropdown-menu">
34905            <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
34906            <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>
34907          </div>
34908        </div>
34909        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
34910          <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>
34911        </button>
34912        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
34913          <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>
34914          <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>
34915        </button>
34916      </div>
34917    </div>
34918  </div>
34919
34920  <div class="page">
34921    <div class="page-header">
34922      <h1 class="page-title">REST API Reference</h1>
34923      <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>
34924    </div>
34925
34926    {% if has_api_key %}
34927    <div class="callout key-set">
34928      <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>
34929      <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>
34930    </div>
34931    {% else %}
34932    <div class="callout no-key">
34933      <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>
34934      <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>
34935    </div>
34936    {% endif %}
34937
34938    <div class="base-url-bar">
34939      <span class="base-url-label">Base URL</span>
34940      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
34941    </div>
34942
34943    <!-- Health -->
34944    <div class="section">
34945      <h2 class="section-title">Health &amp; Status</h2>
34946      <div class="ep-card">
34947        <div class="ep-header">
34948          <span class="method get">GET</span>
34949          <span class="ep-path">/healthz</span>
34950          <span class="auth-badge public">Public</span>
34951          <span class="ep-desc">Server liveness check</span>
34952          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
34953        </div>
34954        <div class="ep-body">
34955          <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>
34956          <p class="params-heading">Response</p>
34957          <div class="schema-block">200 OK
34958Content-Type: text/plain
34959
34960ok</div>
34961          <p class="curl-heading">Example</p>
34962          <div class="curl-wrap">
34963            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
34964            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
34965          </div>
34966        </div>
34967      </div>
34968    </div>
34969
34970    <!-- Badges -->
34971    <div class="section">
34972      <h2 class="section-title">Badges</h2>
34973      <div class="ep-card">
34974        <div class="ep-header">
34975          <span class="method get">GET</span>
34976          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
34977          <span class="auth-badge public">Public</span>
34978          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
34979          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
34980        </div>
34981        <div class="ep-body">
34982          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
34983          <p class="params-heading">Path Parameters</p>
34984          <table class="params">
34985            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
34986            <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>
34987          </table>
34988          <p class="curl-heading">Example</p>
34989          <div class="curl-wrap">
34990            <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>
34991            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
34992          </div>
34993        </div>
34994      </div>
34995    </div>
34996
34997    <!-- Metrics -->
34998    <div class="section">
34999      <h2 class="section-title">Metrics</h2>
35000
35001      <div class="ep-card">
35002        <div class="ep-header">
35003          <span class="method get">GET</span>
35004          <span class="ep-path">/api/metrics/latest</span>
35005          <span class="auth-badge protected">Protected</span>
35006          <span class="ep-desc">Latest scan metrics (JSON)</span>
35007          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35008        </div>
35009        <div class="ep-body">
35010          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
35011          <details class="schema"><summary>Response schema</summary>
35012<div class="schema-block">{
35013  "run_id":    string,        // UUID
35014  "timestamp": string,        // ISO-8601 UTC
35015  "project":   string,        // scanned root path
35016  "summary": {
35017    "files_analyzed":       number,
35018    "files_skipped":        number,
35019    "code_lines":           number,
35020    "comment_lines":        number,
35021    "blank_lines":          number,
35022    "total_physical_lines": number,
35023    "functions":            number,
35024    "classes":              number,
35025    "variables":            number,
35026    "imports":              number
35027  },
35028  "languages": [
35029    { "name": string, "files": number, "code_lines": number,
35030      "comment_lines": number, "blank_lines": number,
35031      "functions": number, "classes": number,
35032      "variables": number, "imports": number }
35033  ]
35034}</div></details>
35035          <p class="curl-heading">Example</p>
35036          <div class="curl-wrap">
35037            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35038  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
35039            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
35040          </div>
35041        </div>
35042      </div>
35043
35044      <div class="ep-card">
35045        <div class="ep-header">
35046          <span class="method get">GET</span>
35047          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
35048          <span class="auth-badge protected">Protected</span>
35049          <span class="ep-desc">Metrics for a specific run</span>
35050          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35051        </div>
35052        <div class="ep-body">
35053          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
35054          <p class="params-heading">Path Parameters</p>
35055          <table class="params">
35056            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35057            <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>
35058          </table>
35059          <p class="curl-heading">Example</p>
35060          <div class="curl-wrap">
35061            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35062  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
35063            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
35064          </div>
35065        </div>
35066      </div>
35067
35068      <div class="ep-card">
35069        <div class="ep-header">
35070          <span class="method get">GET</span>
35071          <span class="ep-path">/api/metrics/history</span>
35072          <span class="auth-badge protected">Protected</span>
35073          <span class="ep-desc">Paginated scan history</span>
35074          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35075        </div>
35076        <div class="ep-body">
35077          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
35078          <p class="params-heading">Query Parameters</p>
35079          <table class="params">
35080            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35081            <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>
35082            <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>
35083          </table>
35084          <details class="schema"><summary>Response schema</summary>
35085<div class="schema-block">[{
35086  "run_id":         string,
35087  "timestamp":      string,   // ISO-8601 UTC
35088  "commit":         string | null,
35089  "branch":         string | null,
35090  "tags":           string[],
35091  "code_lines":     number,
35092  "comment_lines":  number,
35093  "blank_lines":    number,
35094  "physical_lines": number,
35095  "files_analyzed": number,
35096  "project_label":  string,
35097  "html_url":       string | null
35098}]</div></details>
35099          <p class="curl-heading">Example</p>
35100          <div class="curl-wrap">
35101            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35102  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
35103            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
35104          </div>
35105        </div>
35106      </div>
35107
35108      <div class="ep-card">
35109        <div class="ep-header">
35110          <span class="method get">GET</span>
35111          <span class="ep-path">/api/project-history</span>
35112          <span class="auth-badge protected">Protected</span>
35113          <span class="ep-desc">Project-level scan summary</span>
35114          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35115        </div>
35116        <div class="ep-body">
35117          <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>
35118          <p class="params-heading">Query Parameters</p>
35119          <table class="params">
35120            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35121            <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>
35122          </table>
35123          <details class="schema"><summary>Response schema</summary>
35124<div class="schema-block">{
35125  "scan_count":           number,
35126  "last_scan_id":         string | null,
35127  "last_scan_timestamp":  string | null,  // ISO-8601
35128  "last_scan_code_lines": number | null,
35129  "last_git_branch":      string | null,
35130  "last_git_commit":      string | null
35131}</div></details>
35132          <p class="curl-heading">Example</p>
35133          <div class="curl-wrap">
35134            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35135  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
35136            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
35137          </div>
35138        </div>
35139      </div>
35140
35141      <div class="ep-card">
35142        <div class="ep-header">
35143          <span class="method get">GET</span>
35144          <span class="ep-path">/api/metrics/submodules</span>
35145          <span class="auth-badge protected">Protected</span>
35146          <span class="ep-desc">List known git submodules across scans</span>
35147          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35148        </div>
35149        <div class="ep-body">
35150          <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>
35151          <p class="params-heading">Query Parameters</p>
35152          <table class="params">
35153            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35154            <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>
35155          </table>
35156          <details class="schema"><summary>Response schema</summary>
35157<div class="schema-block">[{
35158  "name":          string,  // submodule name
35159  "relative_path": string   // path relative to the project root
35160}]</div></details>
35161          <p class="curl-heading">Example</p>
35162          <div class="curl-wrap">
35163            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35164  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
35165            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
35166          </div>
35167        </div>
35168      </div>
35169    </div>
35170
35171    <!-- Async Run Status -->
35172    <div class="section">
35173      <h2 class="section-title">Async Run Status</h2>
35174
35175      <div class="ep-card">
35176        <div class="ep-header">
35177          <span class="method get">GET</span>
35178          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
35179          <span class="auth-badge protected">Protected</span>
35180          <span class="ep-desc">Poll scan completion</span>
35181          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35182        </div>
35183        <div class="ep-body">
35184          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
35185          <details class="schema"><summary>Response schema</summary>
35186<div class="schema-block">// Running
35187{ "state": "running",  "elapsed_secs": number }
35188
35189// Complete
35190{ "state": "complete", "run_id": string }
35191
35192// Failed
35193{ "state": "failed",   "message": string }</div></details>
35194          <p class="curl-heading">Example</p>
35195          <div class="curl-wrap">
35196            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35197  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
35198            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
35199          </div>
35200        </div>
35201      </div>
35202
35203      <div class="ep-card">
35204        <div class="ep-header">
35205          <span class="method get">GET</span>
35206          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
35207          <span class="auth-badge protected">Protected</span>
35208          <span class="ep-desc">Poll PDF generation readiness</span>
35209          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35210        </div>
35211        <div class="ep-body">
35212          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
35213          <details class="schema"><summary>Response schema</summary>
35214<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
35215          <p class="curl-heading">Example</p>
35216          <div class="curl-wrap">
35217            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35218  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
35219            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
35220          </div>
35221        </div>
35222      </div>
35223
35224      <div class="ep-card">
35225        <div class="ep-header">
35226          <span class="method post">POST</span>
35227          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
35228          <span class="auth-badge protected">Protected</span>
35229          <span class="ep-desc">Cancel a running scan</span>
35230          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35231        </div>
35232        <div class="ep-body">
35233          <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>
35234          <p class="curl-heading">Example</p>
35235          <div class="curl-wrap">
35236            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
35237  -H "Authorization: Bearer $SLOC_API_KEY" \
35238  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
35239            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
35240          </div>
35241        </div>
35242      </div>
35243    </div>
35244
35245    <!-- Run Management -->
35246    <div class="section">
35247      <h2 class="section-title">Run Management</h2>
35248
35249      <div class="ep-card">
35250        <div class="ep-header">
35251          <span class="method get">GET</span>
35252          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
35253          <span class="auth-badge protected">Protected</span>
35254          <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
35255          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35256        </div>
35257        <div class="ep-body">
35258          <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>
35259          <p class="params-heading">Path Parameters</p>
35260          <table class="params">
35261            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35262            <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>
35263          </table>
35264          <details class="schema"><summary>Response</summary>
35265<div class="schema-block">200 OK — Content-Type: application/zip
35266Content-Disposition: attachment; filename="sloc-run-&lt;run_id&gt;.zip"
35267
35268404 Not Found — { "error": string }  (run not found or no artifacts)</div></details>
35269          <p class="curl-heading">Example</p>
35270          <div class="curl-wrap">
35271            <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35272  -o run.zip \
35273  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/bundle</pre>
35274            <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
35275          </div>
35276        </div>
35277      </div>
35278
35279      <div class="ep-card">
35280        <div class="ep-header">
35281          <span class="method delete">DELETE</span>
35282          <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
35283          <span class="auth-badge protected">Protected</span>
35284          <span class="ep-desc">Permanently delete a run and all its artifacts</span>
35285          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35286        </div>
35287        <div class="ep-body">
35288          <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>
35289          <p class="params-heading">Path Parameters</p>
35290          <table class="params">
35291            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35292            <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>
35293          </table>
35294          <details class="schema"><summary>Response</summary>
35295<div class="schema-block">204 No Content — run successfully deleted
35296
35297500 Internal Server Error — { "error": string }  (filesystem deletion failed)</div></details>
35298          <p class="curl-heading">Example</p>
35299          <div class="curl-wrap">
35300            <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
35301  -H "Authorization: Bearer $SLOC_API_KEY" \
35302  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;</pre>
35303            <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
35304          </div>
35305        </div>
35306      </div>
35307
35308      <div class="ep-card">
35309        <div class="ep-header">
35310          <span class="method post">POST</span>
35311          <span class="ep-path">/api/runs/cleanup</span>
35312          <span class="auth-badge protected">Protected</span>
35313          <span class="ep-desc">Bulk delete runs older than N days</span>
35314          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35315        </div>
35316        <div class="ep-body">
35317          <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>
35318          <p class="params-heading">Request Body (application/json)</p>
35319          <table class="params">
35320            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35321            <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>
35322          </table>
35323          <details class="schema"><summary>Response schema</summary>
35324<div class="schema-block">{ "deleted": number }  // count of runs removed</div></details>
35325          <p class="curl-heading">Example — delete runs older than 60 days</p>
35326          <div class="curl-wrap">
35327            <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
35328  -H "Authorization: Bearer $SLOC_API_KEY" \
35329  -H "Content-Type: application/json" \
35330  -d '{"older_than_days":60}' \
35331  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
35332            <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
35333          </div>
35334        </div>
35335      </div>
35336    </div>
35337
35338    <!-- Retention Policy -->
35339    <div class="section">
35340      <h2 class="section-title">Retention Policy</h2>
35341
35342      <div class="ep-card">
35343        <div class="ep-header">
35344          <span class="method get">GET</span>
35345          <span class="ep-path">/api/cleanup-policy</span>
35346          <span class="auth-badge protected">Protected</span>
35347          <span class="ep-desc">Get the current retention policy and last-run metadata</span>
35348          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35349        </div>
35350        <div class="ep-body">
35351          <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>
35352          <details class="schema"><summary>Response schema</summary>
35353<div class="schema-block">{
35354  "policy": {
35355    "enabled":       boolean,
35356    "max_age_days":  number | null,   // delete runs older than N days
35357    "max_run_count": number | null,   // keep only the N most recent runs
35358    "interval_hours": number          // hours between background passes
35359  } | null,
35360  "last_run_at":      string | null,  // ISO-8601 UTC timestamp
35361  "last_run_deleted": number | null   // runs deleted in last pass
35362}</div></details>
35363          <p class="curl-heading">Example</p>
35364          <div class="curl-wrap">
35365            <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35366  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
35367            <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
35368          </div>
35369        </div>
35370      </div>
35371
35372      <div class="ep-card">
35373        <div class="ep-header">
35374          <span class="method post">POST</span>
35375          <span class="ep-path">/api/cleanup-policy</span>
35376          <span class="auth-badge protected">Protected</span>
35377          <span class="ep-desc">Save or update the retention policy</span>
35378          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35379        </div>
35380        <div class="ep-body">
35381          <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>
35382          <p class="params-heading">Request Body (application/json)</p>
35383          <table class="params">
35384            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35385            <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>
35386            <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>
35387            <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>
35388            <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>
35389          </table>
35390          <details class="schema"><summary>Response</summary>
35391<div class="schema-block">204 No Content — policy saved and task (re)started
35392
35393500 Internal Server Error — { "error": string }</div></details>
35394          <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
35395          <div class="curl-wrap">
35396            <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
35397  -H "Authorization: Bearer $SLOC_API_KEY" \
35398  -H "Content-Type: application/json" \
35399  -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
35400  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
35401            <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
35402          </div>
35403        </div>
35404      </div>
35405
35406      <div class="ep-card">
35407        <div class="ep-header">
35408          <span class="method post">POST</span>
35409          <span class="ep-path">/api/cleanup-policy/run-now</span>
35410          <span class="auth-badge protected">Protected</span>
35411          <span class="ep-desc">Trigger an immediate cleanup pass</span>
35412          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35413        </div>
35414        <div class="ep-body">
35415          <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>
35416          <details class="schema"><summary>Response schema</summary>
35417<div class="schema-block">{ "deleted": number }  // count of runs removed in this pass</div></details>
35418          <p class="curl-heading">Example</p>
35419          <div class="curl-wrap">
35420            <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
35421  -H "Authorization: Bearer $SLOC_API_KEY" \
35422  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
35423            <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
35424          </div>
35425        </div>
35426      </div>
35427
35428      <div class="ep-card">
35429        <div class="ep-header">
35430          <span class="method delete">DELETE</span>
35431          <span class="ep-path">/api/cleanup-policy</span>
35432          <span class="auth-badge protected">Protected</span>
35433          <span class="ep-desc">Remove the retention policy and stop the background task</span>
35434          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35435        </div>
35436        <div class="ep-body">
35437          <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>
35438          <details class="schema"><summary>Response</summary>
35439<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
35440          <p class="curl-heading">Example</p>
35441          <div class="curl-wrap">
35442            <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
35443  -H "Authorization: Bearer $SLOC_API_KEY" \
35444  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
35445            <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
35446          </div>
35447        </div>
35448      </div>
35449    </div>
35450
35451    <!-- Scan Profiles -->
35452    <div class="section">
35453      <h2 class="section-title">Scan Profiles</h2>
35454
35455      <div class="ep-card">
35456        <div class="ep-header">
35457          <span class="method get">GET</span>
35458          <span class="ep-path">/api/scan-profiles</span>
35459          <span class="auth-badge protected">Protected</span>
35460          <span class="ep-desc">List saved scan profiles</span>
35461          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35462        </div>
35463        <div class="ep-body">
35464          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
35465          <details class="schema"><summary>Response schema</summary>
35466<div class="schema-block">{
35467  "profiles": [{
35468    "id":         string,   // UUID
35469    "name":       string,
35470    "created_at": string,   // ISO-8601
35471    "params":     object
35472  }]
35473}</div></details>
35474          <p class="curl-heading">Example</p>
35475          <div class="curl-wrap">
35476            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35477  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
35478            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
35479          </div>
35480        </div>
35481      </div>
35482
35483      <div class="ep-card">
35484        <div class="ep-header">
35485          <span class="method post">POST</span>
35486          <span class="ep-path">/api/scan-profiles</span>
35487          <span class="auth-badge protected">Protected</span>
35488          <span class="ep-desc">Save a scan profile</span>
35489          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35490        </div>
35491        <div class="ep-body">
35492          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
35493          <p class="params-heading">Request Body (application/json)</p>
35494          <table class="params">
35495            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35496            <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>
35497            <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>
35498          </table>
35499          <details class="schema"><summary>Response schema</summary>
35500<div class="schema-block">{ "ok": true }</div></details>
35501          <p class="curl-heading">Example</p>
35502          <div class="curl-wrap">
35503            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
35504  -H "Authorization: Bearer $SLOC_API_KEY" \
35505  -H "Content-Type: application/json" \
35506  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
35507  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
35508            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
35509          </div>
35510        </div>
35511      </div>
35512
35513      <div class="ep-card">
35514        <div class="ep-header">
35515          <span class="method delete">DELETE</span>
35516          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
35517          <span class="auth-badge protected">Protected</span>
35518          <span class="ep-desc">Delete a scan profile</span>
35519          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35520        </div>
35521        <div class="ep-body">
35522          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
35523          <p class="params-heading">Path Parameters</p>
35524          <table class="params">
35525            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35526            <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>
35527          </table>
35528          <details class="schema"><summary>Response schema</summary>
35529<div class="schema-block">{ "ok": true }</div></details>
35530          <p class="curl-heading">Example</p>
35531          <div class="curl-wrap">
35532            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
35533  -H "Authorization: Bearer $SLOC_API_KEY" \
35534  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
35535            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
35536          </div>
35537        </div>
35538      </div>
35539    </div>
35540
35541    <!-- Scheduled Scans -->
35542    <div class="section">
35543      <h2 class="section-title">Scheduled Scans</h2>
35544
35545      <div class="ep-card">
35546        <div class="ep-header">
35547          <span class="method get">GET</span>
35548          <span class="ep-path">/api/schedules</span>
35549          <span class="auth-badge protected">Protected</span>
35550          <span class="ep-desc">List configured schedules</span>
35551          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35552        </div>
35553        <div class="ep-body">
35554          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
35555          <p class="curl-heading">Example</p>
35556          <div class="curl-wrap">
35557            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35558  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
35559            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
35560          </div>
35561        </div>
35562      </div>
35563
35564      <div class="ep-card">
35565        <div class="ep-header">
35566          <span class="method post">POST</span>
35567          <span class="ep-path">/api/schedules</span>
35568          <span class="auth-badge protected">Protected</span>
35569          <span class="ep-desc">Create a schedule</span>
35570          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35571        </div>
35572        <div class="ep-body">
35573          <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>
35574          <p class="curl-heading">Example</p>
35575          <div class="curl-wrap">
35576            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
35577  -H "Authorization: Bearer $SLOC_API_KEY" \
35578  -H "Content-Type: application/json" \
35579  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
35580  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
35581            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
35582          </div>
35583        </div>
35584      </div>
35585
35586      <div class="ep-card">
35587        <div class="ep-header">
35588          <span class="method delete">DELETE</span>
35589          <span class="ep-path">/api/schedules</span>
35590          <span class="auth-badge protected">Protected</span>
35591          <span class="ep-desc">Delete a schedule</span>
35592          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35593        </div>
35594        <div class="ep-body">
35595          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
35596          <p class="curl-heading">Example</p>
35597          <div class="curl-wrap">
35598            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
35599  -H "Authorization: Bearer $SLOC_API_KEY" \
35600  -H "Content-Type: application/json" \
35601  -d '{"id":"&lt;schedule_id&gt;"}' \
35602  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
35603            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
35604          </div>
35605        </div>
35606      </div>
35607    </div>
35608
35609    <!-- Git Browser -->
35610    <div class="section">
35611      <h2 class="section-title">Git Browser</h2>
35612
35613      <div class="ep-card">
35614        <div class="ep-header">
35615          <span class="method get">GET</span>
35616          <span class="ep-path">/api/git/refs</span>
35617          <span class="auth-badge protected">Protected</span>
35618          <span class="ep-desc">List git refs for a repository</span>
35619          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35620        </div>
35621        <div class="ep-body">
35622          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
35623          <p class="params-heading">Query Parameters</p>
35624          <table class="params">
35625            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35626            <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
35627          </table>
35628          <p class="curl-heading">Example</p>
35629          <div class="curl-wrap">
35630            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35631  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
35632            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
35633          </div>
35634        </div>
35635      </div>
35636
35637      <div class="ep-card">
35638        <div class="ep-header">
35639          <span class="method get">GET</span>
35640          <span class="ep-path">/api/git/scan-ref</span>
35641          <span class="auth-badge protected">Protected</span>
35642          <span class="ep-desc">SLOC-scan a specific git ref</span>
35643          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35644        </div>
35645        <div class="ep-body">
35646          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
35647          <p class="params-heading">Query Parameters</p>
35648          <table class="params">
35649            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35650            <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
35651            <tr><td class="pt-name">ref_name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
35652          </table>
35653          <p class="curl-heading">Example</p>
35654          <div class="curl-wrap">
35655            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35656  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&amp;ref_name=main"</pre>
35657            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
35658          </div>
35659        </div>
35660      </div>
35661
35662      <div class="ep-card">
35663        <div class="ep-header">
35664          <span class="method get">GET</span>
35665          <span class="ep-path">/api/git/compare-refs</span>
35666          <span class="auth-badge protected">Protected</span>
35667          <span class="ep-desc">Compare SLOC across two git refs</span>
35668          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35669        </div>
35670        <div class="ep-body">
35671          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
35672          <p class="params-heading">Query Parameters</p>
35673          <table class="params">
35674            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35675            <tr><td class="pt-name">repo</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
35676            <tr><td class="pt-name">baseline_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
35677            <tr><td class="pt-name">current_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
35678          </table>
35679          <p class="curl-heading">Example</p>
35680          <div class="curl-wrap">
35681            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35682  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?repo=/path/to/repo&amp;baseline_ref=v1.0&amp;current_ref=main"</pre>
35683            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
35684          </div>
35685        </div>
35686      </div>
35687    </div>
35688
35689    <!-- Webhooks -->
35690    <div class="section">
35691      <h2 class="section-title">Webhooks</h2>
35692      <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>
35693
35694      <div class="ep-card">
35695        <div class="ep-header">
35696          <span class="method post">POST</span>
35697          <span class="ep-path">/webhooks/github</span>
35698          <span class="auth-badge hmac">HMAC</span>
35699          <span class="ep-desc">GitHub push event receiver</span>
35700          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35701        </div>
35702        <div class="ep-body">
35703          <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>
35704          <p class="params-heading">Required Headers</p>
35705          <table class="params">
35706            <tr><th>Header</th><th>Value</th></tr>
35707            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
35708            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
35709            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
35710          </table>
35711        </div>
35712      </div>
35713
35714      <div class="ep-card">
35715        <div class="ep-header">
35716          <span class="method post">POST</span>
35717          <span class="ep-path">/webhooks/gitlab</span>
35718          <span class="auth-badge hmac">HMAC</span>
35719          <span class="ep-desc">GitLab push event receiver</span>
35720          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35721        </div>
35722        <div class="ep-body">
35723          <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>
35724          <p class="params-heading">Required Headers</p>
35725          <table class="params">
35726            <tr><th>Header</th><th>Value</th></tr>
35727            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
35728            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
35729            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
35730          </table>
35731        </div>
35732      </div>
35733
35734      <div class="ep-card">
35735        <div class="ep-header">
35736          <span class="method post">POST</span>
35737          <span class="ep-path">/webhooks/bitbucket</span>
35738          <span class="auth-badge hmac">HMAC</span>
35739          <span class="ep-desc">Bitbucket push event receiver</span>
35740          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35741        </div>
35742        <div class="ep-body">
35743          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
35744          <p class="params-heading">Required Headers</p>
35745          <table class="params">
35746            <tr><th>Header</th><th>Value</th></tr>
35747            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
35748            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
35749          </table>
35750        </div>
35751      </div>
35752    </div>
35753
35754    <!-- Config -->
35755    <div class="section">
35756      <h2 class="section-title">Config Import / Export</h2>
35757
35758      <div class="ep-card">
35759        <div class="ep-header">
35760          <span class="method get">GET</span>
35761          <span class="ep-path">/export-config</span>
35762          <span class="auth-badge protected">Protected</span>
35763          <span class="ep-desc">Export server configuration as JSON</span>
35764          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35765        </div>
35766        <div class="ep-body">
35767          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
35768          <p class="curl-heading">Example</p>
35769          <div class="curl-wrap">
35770            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35771  -o config.json \
35772  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
35773            <button class="curl-copy-btn" data-target="c-export">Copy</button>
35774          </div>
35775        </div>
35776      </div>
35777
35778      <div class="ep-card">
35779        <div class="ep-header">
35780          <span class="method post">POST</span>
35781          <span class="ep-path">/import-config</span>
35782          <span class="auth-badge protected">Protected</span>
35783          <span class="ep-desc">Import server configuration</span>
35784          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35785        </div>
35786        <div class="ep-body">
35787          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
35788          <p class="curl-heading">Example</p>
35789          <div class="curl-wrap">
35790            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
35791  -H "Authorization: Bearer $SLOC_API_KEY" \
35792  -H "Content-Type: application/json" \
35793  -d @config.json \
35794  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
35795            <button class="curl-copy-btn" data-target="c-import">Copy</button>
35796          </div>
35797        </div>
35798      </div>
35799    </div>
35800
35801    <!-- CI Ingest -->
35802    <div class="section">
35803      <h2 class="section-title">CI Ingest</h2>
35804
35805      <div class="ep-card">
35806        <div class="ep-header">
35807          <span class="method post">POST</span>
35808          <span class="ep-path">/api/ingest</span>
35809          <span class="auth-badge protected">Protected</span>
35810          <span class="ep-desc">Push a pre-computed scan result from CI</span>
35811          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35812        </div>
35813        <div class="ep-body">
35814          <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>
35815          <p class="params-heading">Query Parameters</p>
35816          <table class="params">
35817            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35818            <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>
35819          </table>
35820          <p class="params-heading">Request Body (application/json)</p>
35821          <p class="sx-73b3d091" >Full <code>AnalysisRun</code> JSON as produced by the CLI <code>--json-out</code> flag.</p>
35822          <details class="schema"><summary>Response schema</summary>
35823<div class="schema-block">// 201 Created
35824{
35825  "run_id":   string,  // UUID of the ingested run
35826  "view_url": string   // relative URL to the report page
35827}</div></details>
35828          <p class="curl-heading">Example</p>
35829          <div class="curl-wrap">
35830            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
35831  -H "Authorization: Bearer $SLOC_API_KEY" \
35832  -H "Content-Type: application/json" \
35833  -d @result.json \
35834  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
35835            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
35836          </div>
35837        </div>
35838      </div>
35839    </div>
35840
35841    <!-- Artifact Download -->
35842    <div class="section">
35843      <h2 class="section-title">Artifact Download</h2>
35844
35845      <div class="ep-card">
35846        <div class="ep-header">
35847          <span class="method get">GET</span>
35848          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
35849          <span class="auth-badge protected">Protected</span>
35850          <span class="ep-desc">Download or view a scan artifact</span>
35851          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35852        </div>
35853        <div class="ep-body">
35854          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
35855          <p class="params-heading">Path Parameters</p>
35856          <table class="params">
35857            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35858            <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>
35859            <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>
35860          </table>
35861          <p class="params-heading">Query Parameters</p>
35862          <table class="params">
35863            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35864            <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>
35865          </table>
35866          <p class="curl-heading">Example — download JSON result</p>
35867          <div class="curl-wrap">
35868            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35869  -o result.json \
35870  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
35871            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
35872          </div>
35873        </div>
35874      </div>
35875    </div>
35876
35877    <!-- Embed Widget -->
35878    <div class="section">
35879      <h2 class="section-title">Embed Widget</h2>
35880
35881      <div class="ep-card">
35882        <div class="ep-header">
35883          <span class="method get">GET</span>
35884          <span class="ep-path">/embed/summary</span>
35885          <span class="auth-badge protected">Protected</span>
35886          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
35887          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35888        </div>
35889        <div class="ep-body">
35890          <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>
35891          <p class="params-heading">Query Parameters</p>
35892          <table class="params">
35893            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35894            <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>
35895            <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>
35896          </table>
35897          <p class="curl-heading">Example</p>
35898          <div class="curl-wrap">
35899            <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"
35900        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
35901            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
35902          </div>
35903        </div>
35904      </div>
35905    </div>
35906
35907    <!-- Confluence Integration -->
35908    <div class="section">
35909      <h2 class="section-title">Confluence Integration</h2>
35910
35911      <div class="ep-card">
35912        <div class="ep-header">
35913          <span class="method get">GET</span>
35914          <span class="ep-path">/api/confluence/config</span>
35915          <span class="auth-badge protected">Protected</span>
35916          <span class="ep-desc">Get current Confluence configuration</span>
35917          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35918        </div>
35919        <div class="ep-body">
35920          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
35921          <details class="schema"><summary>Response schema</summary>
35922<div class="schema-block">{
35923  "configured":     boolean,
35924  "tier":           "cloud" | "server",
35925  "base_url":       string,
35926  "username":       string,
35927  "api_token_set":  boolean,
35928  "space_key":      string,
35929  "parent_page_id": string | null,
35930  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
35931}</div></details>
35932          <p class="curl-heading">Example</p>
35933          <div class="curl-wrap">
35934            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35935  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
35936            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
35937          </div>
35938        </div>
35939      </div>
35940
35941      <div class="ep-card">
35942        <div class="ep-header">
35943          <span class="method post">POST</span>
35944          <span class="ep-path">/api/confluence/config</span>
35945          <span class="auth-badge protected">Protected</span>
35946          <span class="ep-desc">Save Confluence configuration</span>
35947          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35948        </div>
35949        <div class="ep-body">
35950          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
35951          <p class="params-heading">Request Body (application/json)</p>
35952          <table class="params">
35953            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35954            <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>
35955            <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>
35956            <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>
35957            <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>
35958            <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>
35959            <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>
35960            <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>
35961          </table>
35962          <details class="schema"><summary>Response schema</summary>
35963<div class="schema-block">{ "ok": true }</div></details>
35964          <p class="curl-heading">Example</p>
35965          <div class="curl-wrap">
35966            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
35967  -H "Authorization: Bearer $SLOC_API_KEY" \
35968  -H "Content-Type: application/json" \
35969  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
35970  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
35971            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
35972          </div>
35973        </div>
35974      </div>
35975
35976      <div class="ep-card">
35977        <div class="ep-header">
35978          <span class="method post">POST</span>
35979          <span class="ep-path">/api/confluence/test</span>
35980          <span class="auth-badge protected">Protected</span>
35981          <span class="ep-desc">Test Confluence connection</span>
35982          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35983        </div>
35984        <div class="ep-body">
35985          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
35986          <details class="schema"><summary>Response schema</summary>
35987<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
35988          <p class="curl-heading">Example</p>
35989          <div class="curl-wrap">
35990            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
35991  -H "Authorization: Bearer $SLOC_API_KEY" \
35992  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
35993            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
35994          </div>
35995        </div>
35996      </div>
35997
35998      <div class="ep-card">
35999        <div class="ep-header">
36000          <span class="method post">POST</span>
36001          <span class="ep-path">/api/confluence/post</span>
36002          <span class="auth-badge protected">Protected</span>
36003          <span class="ep-desc">Publish a scan report to Confluence</span>
36004          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36005        </div>
36006        <div class="ep-body">
36007          <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>
36008          <p class="params-heading">Request Body (application/json)</p>
36009          <table class="params">
36010            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
36011            <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>
36012            <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>
36013            <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>
36014          </table>
36015          <details class="schema"><summary>Response schema</summary>
36016<div class="schema-block">// 200 OK
36017{ "ok": true, "page_id": string }
36018
36019// 400 / 502 on error
36020{ "ok": false, "error": string }</div></details>
36021          <p class="curl-heading">Example</p>
36022          <div class="curl-wrap">
36023            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
36024  -H "Authorization: Bearer $SLOC_API_KEY" \
36025  -H "Content-Type: application/json" \
36026  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
36027  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
36028            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
36029          </div>
36030        </div>
36031      </div>
36032
36033      <div class="ep-card">
36034        <div class="ep-header">
36035          <span class="method get">GET</span>
36036          <span class="ep-path">/api/confluence/wiki-markup</span>
36037          <span class="auth-badge protected">Protected</span>
36038          <span class="ep-desc">Get Confluence wiki markup for a run</span>
36039          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36040        </div>
36041        <div class="ep-body">
36042          <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>
36043          <p class="params-heading">Query Parameters</p>
36044          <table class="params">
36045            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
36046            <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>
36047          </table>
36048          <p class="curl-heading">Example</p>
36049          <div class="curl-wrap">
36050            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
36051  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
36052            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
36053          </div>
36054        </div>
36055      </div>
36056    </div>
36057
36058    <!-- Authentication -->
36059    <div class="section">
36060      <h2 class="section-title">Authentication</h2>
36061      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
36062
36063      <div class="ep-card">
36064        <div class="ep-header">
36065          <span class="method get">GET</span>
36066          <span class="ep-path">/auth/login</span>
36067          <span class="auth-badge public">Public</span>
36068          <span class="ep-desc">Login page</span>
36069          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36070        </div>
36071        <div class="ep-body">
36072          <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>
36073          <p class="params-heading">Query Parameters</p>
36074          <table class="params">
36075            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
36076            <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>
36077            <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>
36078          </table>
36079        </div>
36080      </div>
36081
36082      <div class="ep-card">
36083        <div class="ep-header">
36084          <span class="method post">POST</span>
36085          <span class="ep-path">/auth/login</span>
36086          <span class="auth-badge public">Public</span>
36087          <span class="ep-desc">Submit credentials and get a session cookie</span>
36088          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36089        </div>
36090        <div class="ep-body">
36091          <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>
36092          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
36093          <table class="params">
36094            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
36095            <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>
36096            <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>
36097          </table>
36098          <p class="curl-heading">Example</p>
36099          <div class="curl-wrap">
36100            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
36101  -d "key=$SLOC_API_KEY&amp;next=/" \
36102  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
36103            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
36104          </div>
36105        </div>
36106      </div>
36107    </div>
36108
36109    <!-- Coverage Suggestion -->
36110    <div class="section">
36111      <h2 class="section-title">Coverage Suggestion</h2>
36112
36113      <div class="ep-card">
36114        <div class="ep-header">
36115          <span class="method get">GET</span>
36116          <span class="ep-path">/api/suggest-coverage</span>
36117          <span class="auth-badge protected">Protected</span>
36118          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
36119          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36120        </div>
36121        <div class="ep-body">
36122          <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>
36123          <p class="params-heading">Query Parameters</p>
36124          <table class="params">
36125            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
36126            <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>
36127          </table>
36128          <details class="schema"><summary>Response schema</summary>
36129<div class="schema-block">{
36130  "found": string | null,  // absolute path to the coverage file, if detected
36131  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
36132  "hint":  string | null   // shell command to generate coverage if not found
36133}</div></details>
36134          <p class="curl-heading">Example</p>
36135          <div class="curl-wrap">
36136            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
36137  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
36138            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
36139          </div>
36140        </div>
36141      </div>
36142    </div>
36143
36144  </div>
36145
36146  <footer class="site-footer">
36147    local code analysis - metrics, history and reports
36148    &nbsp;·&nbsp; <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
36149    &nbsp;·&nbsp; Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
36150    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
36151    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
36152    &nbsp;·&nbsp; <a href="/report-bug" rel="noopener">Report a Bug</a> &nbsp;&middot;&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
36153  </footer>
36154
36155  <script nonce="{{ csp_nonce }}">
36156    (function () {
36157      var base = window.location.origin;
36158      document.getElementById('base-url').textContent = base;
36159      document.querySelectorAll('.base-url-slot').forEach(function (el) {
36160        el.textContent = base;
36161      });
36162
36163      document.querySelectorAll('.ep-header').forEach(function (hdr) {
36164        hdr.addEventListener('click', function () {
36165          hdr.closest('.ep-card').classList.toggle('open');
36166        });
36167      });
36168
36169      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
36170        btn.addEventListener('click', function () {
36171          var targetId = btn.dataset.target;
36172          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
36173          if (!pre) return;
36174          navigator.clipboard.writeText(pre.textContent).then(function () {
36175            btn.textContent = 'Copied!';
36176            btn.classList.add('copied');
36177            setTimeout(function () {
36178              btn.textContent = 'Copy';
36179              btn.classList.remove('copied');
36180            }, 2000);
36181          });
36182        });
36183      });
36184
36185      var storageKey = 'oxide-sloc-theme';
36186      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
36187      var themeBtn = document.getElementById('theme-toggle');
36188      if (themeBtn) {
36189        themeBtn.addEventListener('click', function () {
36190          var dark = document.body.classList.toggle('dark-theme');
36191          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
36192        });
36193      }
36194      (function() {
36195        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'}];
36196        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);});}
36197        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
36198        var btn=document.getElementById('settings-btn');if(!btn)return;
36199        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
36200        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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
36201        document.body.appendChild(m);
36202        var g=document.getElementById('scheme-grid');
36203        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);});
36204        var cl=document.getElementById('settings-close');
36205        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);});})();
36206        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');});
36207        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
36208        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
36209      })();
36210      (function randomizeWatermarks() {
36211        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
36212        if (!wms.length) return;
36213        var placed = [];
36214        function tooClose(top, left) {
36215          for (var i = 0; i < placed.length; i++) {
36216            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
36217            if (dt < 16 && dl < 12) return true;
36218          }
36219          return false;
36220        }
36221        function pick(leftBand) {
36222          for (var attempt = 0; attempt < 50; attempt++) {
36223            var top = Math.random() * 88 + 2;
36224            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
36225            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
36226          }
36227          var top = Math.random() * 88 + 2;
36228          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
36229          placed.push([top, left]); return [top, left];
36230        }
36231        var half = Math.floor(wms.length / 2);
36232        wms.forEach(function (img, i) {
36233          var pos = pick(i < half);
36234          var size = Math.floor(Math.random() * 100 + 120);
36235          var rot = (Math.random() * 360).toFixed(1);
36236          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
36237          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;
36238        });
36239      })();
36240      (function spawnCodeParticles() {
36241        var container = document.getElementById('code-particles');
36242        if (!container) return;
36243        var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
36244        var count = 44;
36245        for (var i = 0; i < count; i++) {
36246          (function(idx) {
36247            var el = document.createElement('span');
36248            el.className = 'code-particle';
36249            el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
36250            var left = Math.random() * 94 + 2;
36251            var top = Math.random() * 88 + 6;
36252            var dur = (Math.random() * 10 + 9).toFixed(1);
36253            var delay = (Math.random() * 18).toFixed(1);
36254            var rot = (Math.random() * 26 - 13).toFixed(1);
36255            var op = (Math.random() * 0.108 + 0.072).toFixed(3);
36256            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';
36257            container.appendChild(el);
36258          })(i);
36259        }
36260      })();
36261    }());
36262  </script>
36263</body>
36264</html>
36265"##,
36266    ext = "html"
36267)]
36268struct ApiDocsTemplate {
36269    has_api_key: bool,
36270    csp_nonce: String,
36271    version: &'static str,
36272}
36273
36274#[cfg(test)]
36275mod form_config_tests {
36276    use super::*;
36277    use sloc_config::{
36278        BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
36279    };
36280
36281    fn blank_form() -> AnalyzeForm {
36282        AnalyzeForm {
36283            path: ".".to_string(),
36284            git_repo: None,
36285            git_ref: None,
36286            mixed_line_policy: None,
36287            python_docstrings_as_comments: None,
36288            generated_file_detection: None,
36289            minified_file_detection: None,
36290            vendor_directory_detection: None,
36291            include_lockfiles: None,
36292            binary_file_behavior: None,
36293            output_dir: None,
36294            report_title: None,
36295            report_header_footer: None,
36296            include_globs: None,
36297            exclude_globs: None,
36298            submodule_breakdown: None,
36299            coverage_file: None,
36300            continuation_line_policy: None,
36301            blank_in_block_comment_policy: None,
36302            count_compiler_directives: None,
36303            style_col_threshold: None,
36304            style_analysis_enabled: None,
36305            style_score_threshold: None,
36306            style_lang_scope: None,
36307            cocomo_mode: None,
36308            complexity_alert: None,
36309            exclude_duplicates: None,
36310            activity_window: None,
36311            attribution: None,
36312        }
36313    }
36314
36315    fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
36316        let mut cfg = sloc_config::AppConfig::default();
36317        apply_form_to_config(&mut cfg, form);
36318        cfg
36319    }
36320
36321    // ── path_within_allowed_roots (server-mode local-path gate) ──
36322
36323    #[test]
36324    fn path_within_allowed_roots_accepts_inside_and_rejects_outside() {
36325        let root = tempfile::tempdir().unwrap();
36326        let inside = root.path().join("proj");
36327        std::fs::create_dir_all(&inside).unwrap();
36328        let inside_canon = std::fs::canonicalize(&inside).unwrap();
36329        let allowed = vec![root.path().to_path_buf()];
36330        assert!(
36331            path_within_allowed_roots(&inside_canon, &allowed),
36332            "a path under an allowed root must be accepted"
36333        );
36334
36335        let outside = tempfile::tempdir().unwrap();
36336        let outside_canon = std::fs::canonicalize(outside.path()).unwrap();
36337        assert!(
36338            !path_within_allowed_roots(&outside_canon, &allowed),
36339            "a path outside every allowed root must be rejected"
36340        );
36341        // An empty allowlist denies everything (the fail-closed server default).
36342        assert!(!path_within_allowed_roots(&inside_canon, &[]));
36343    }
36344
36345    // ── attribution (per-author code ownership — on by default) ──
36346
36347    #[test]
36348    fn attribution_on_by_default_and_toggle_disables() {
36349        let mut form = blank_form();
36350        assert!(apply(&form).analysis.attribution, "on unless disabled");
36351        form.attribution = Some("enabled".to_string());
36352        assert!(apply(&form).analysis.attribution, "'enabled' keeps it on");
36353        form.attribution = Some("disabled".to_string());
36354        assert!(
36355            !apply(&form).analysis.attribution,
36356            "'disabled' turns it off"
36357        );
36358    }
36359
36360    #[test]
36361    fn humanize_duration_formats_seconds_and_minutes() {
36362        assert_eq!(humanize_duration(0), "");
36363        assert_eq!(humanize_duration(3), "~3s");
36364        assert_eq!(humanize_duration(59), "~59s");
36365        assert_eq!(humanize_duration(60), "~1 min");
36366        assert_eq!(humanize_duration(61), "~2 min"); // rounds up
36367        assert_eq!(humanize_duration(600), "~10 min");
36368    }
36369
36370    #[test]
36371    fn attrib_estimate_unknown_is_neutral() {
36372        let r = attrib_estimate_unknown();
36373        assert!(!r.estimate.is_git);
36374        assert!(r.estimate.recommend_attribution);
36375        assert_eq!(r.estimate.blameable_files, 0);
36376        assert!(r.estimated_label.is_empty());
36377    }
36378
36379    // ── activity_window (git hotspots — on by default) ──
36380
36381    #[test]
36382    fn extract_long_commit_picks_super_repo_by_short_prefix() {
36383        // A pretty-printed JSON tail containing several submodule git_commit_long
36384        // values plus the super-repo's; the helper must return the one whose hash
36385        // starts with the known short SHA, ignoring the others and any null value.
36386        let dir = tempfile::tempdir().unwrap();
36387        let path = dir.path().join("result.json");
36388        let body = r#"{
36389  "submodules": [
36390    { "git_commit_long": "aaaa111122223333444455556666777788889999" },
36391    { "git_commit_long": null }
36392  ],
36393  "git_commit_short": "4c2cd9b",
36394  "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
36395}"#;
36396        std::fs::write(&path, body).unwrap();
36397        assert_eq!(
36398            super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
36399            Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
36400        );
36401        // No match for an unrelated short SHA, and empty short yields None.
36402        assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
36403        assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
36404    }
36405
36406    #[test]
36407    fn activity_window_defaults_on_when_field_blank() {
36408        // Blank form field keeps the config default (90 days).
36409        let cfg = apply(&blank_form());
36410        assert_eq!(cfg.analysis.activity_window_days, Some(90));
36411    }
36412
36413    #[test]
36414    fn activity_window_override_sets_days() {
36415        let mut form = blank_form();
36416        form.activity_window = Some("30".to_string());
36417        let cfg = apply(&form);
36418        assert_eq!(cfg.analysis.activity_window_days, Some(30));
36419    }
36420
36421    #[test]
36422    fn activity_window_zero_disables() {
36423        // An explicit 0 from the form disables hotspots (overrides the default-on).
36424        let mut form = blank_form();
36425        form.activity_window = Some("0".to_string());
36426        let cfg = apply(&form);
36427        assert_eq!(cfg.analysis.activity_window_days, Some(0));
36428    }
36429
36430    // ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
36431
36432    #[test]
36433    fn python_docstrings_false_when_unchecked() {
36434        // Checkbox absent in form data (unchecked) → field must be false.
36435        let cfg = apply(&blank_form());
36436        assert!(
36437            !cfg.analysis.python_docstrings_as_comments,
36438            "absent python_docstrings_as_comments must map to false"
36439        );
36440    }
36441
36442    #[test]
36443    fn python_docstrings_true_when_checked() {
36444        // Browser sends "on" (no value= attr on the checkbox).
36445        let mut form = blank_form();
36446        form.python_docstrings_as_comments = Some("on".to_string());
36447        let cfg = apply(&form);
36448        assert!(cfg.analysis.python_docstrings_as_comments);
36449    }
36450
36451    #[test]
36452    fn python_docstrings_true_for_any_non_none_value() {
36453        // The handler uses .is_some() — any non-None value means "checked".
36454        let mut form = blank_form();
36455        form.python_docstrings_as_comments = Some("true".to_string());
36456        assert!(apply(&form).analysis.python_docstrings_as_comments);
36457    }
36458
36459    // ── submodule_breakdown (checkbox with value="enabled") ──
36460
36461    #[test]
36462    fn submodule_breakdown_false_when_unchecked() {
36463        let cfg = apply(&blank_form());
36464        assert!(
36465            !cfg.discovery.submodule_breakdown,
36466            "absent submodule_breakdown must map to false"
36467        );
36468    }
36469
36470    #[test]
36471    fn submodule_breakdown_true_when_value_enabled() {
36472        let mut form = blank_form();
36473        form.submodule_breakdown = Some("enabled".to_string());
36474        assert!(apply(&form).discovery.submodule_breakdown);
36475    }
36476
36477    #[test]
36478    fn submodule_breakdown_false_for_wrong_value() {
36479        // If somehow a value other than "enabled" is sent, it must still be false.
36480        let mut form = blank_form();
36481        form.submodule_breakdown = Some("on".to_string());
36482        assert!(
36483            !apply(&form).discovery.submodule_breakdown,
36484            "submodule_breakdown only becomes true for the exact value 'enabled'"
36485        );
36486    }
36487
36488    // ── generated_file_detection (select: "enabled" | "disabled") ──
36489
36490    #[test]
36491    fn generated_detection_true_when_enabled() {
36492        let mut form = blank_form();
36493        form.generated_file_detection = Some("enabled".to_string());
36494        assert!(apply(&form).analysis.generated_file_detection);
36495    }
36496
36497    #[test]
36498    fn generated_detection_false_when_disabled() {
36499        let mut form = blank_form();
36500        form.generated_file_detection = Some("disabled".to_string());
36501        assert!(!apply(&form).analysis.generated_file_detection);
36502    }
36503
36504    #[test]
36505    fn generated_detection_true_when_absent() {
36506        // None != Some("disabled") → true (safe default)
36507        assert!(
36508            apply(&blank_form()).analysis.generated_file_detection,
36509            "absent field must default to true (detection on)"
36510        );
36511    }
36512
36513    // ── minified_file_detection ──
36514
36515    #[test]
36516    fn minified_detection_false_when_disabled() {
36517        let mut form = blank_form();
36518        form.minified_file_detection = Some("disabled".to_string());
36519        assert!(!apply(&form).analysis.minified_file_detection);
36520    }
36521
36522    #[test]
36523    fn minified_detection_true_when_enabled() {
36524        let mut form = blank_form();
36525        form.minified_file_detection = Some("enabled".to_string());
36526        assert!(apply(&form).analysis.minified_file_detection);
36527    }
36528
36529    #[test]
36530    fn minified_detection_true_when_absent() {
36531        assert!(apply(&blank_form()).analysis.minified_file_detection);
36532    }
36533
36534    // ── vendor_directory_detection ──
36535
36536    #[test]
36537    fn vendor_detection_false_when_disabled() {
36538        let mut form = blank_form();
36539        form.vendor_directory_detection = Some("disabled".to_string());
36540        assert!(!apply(&form).analysis.vendor_directory_detection);
36541    }
36542
36543    #[test]
36544    fn vendor_detection_true_when_enabled() {
36545        let mut form = blank_form();
36546        form.vendor_directory_detection = Some("enabled".to_string());
36547        assert!(apply(&form).analysis.vendor_directory_detection);
36548    }
36549
36550    #[test]
36551    fn vendor_detection_true_when_absent() {
36552        assert!(apply(&blank_form()).analysis.vendor_directory_detection);
36553    }
36554
36555    // ── include_lockfiles (select: "disabled" default | "enabled") ──
36556
36557    #[test]
36558    fn lockfiles_false_when_absent() {
36559        // None == Some("enabled") is false → lockfiles off (correct safe default)
36560        assert!(!apply(&blank_form()).analysis.include_lockfiles);
36561    }
36562
36563    #[test]
36564    fn lockfiles_false_when_disabled() {
36565        let mut form = blank_form();
36566        form.include_lockfiles = Some("disabled".to_string());
36567        assert!(!apply(&form).analysis.include_lockfiles);
36568    }
36569
36570    #[test]
36571    fn lockfiles_true_when_enabled() {
36572        let mut form = blank_form();
36573        form.include_lockfiles = Some("enabled".to_string());
36574        assert!(apply(&form).analysis.include_lockfiles);
36575    }
36576
36577    // ── count_compiler_directives ──
36578
36579    #[test]
36580    fn compiler_directives_true_when_absent() {
36581        assert!(
36582            apply(&blank_form()).analysis.count_compiler_directives,
36583            "absent count_compiler_directives must default to true"
36584        );
36585    }
36586
36587    #[test]
36588    fn compiler_directives_true_when_enabled() {
36589        let mut form = blank_form();
36590        form.count_compiler_directives = Some("enabled".to_string());
36591        assert!(apply(&form).analysis.count_compiler_directives);
36592    }
36593
36594    #[test]
36595    fn compiler_directives_false_when_disabled() {
36596        let mut form = blank_form();
36597        form.count_compiler_directives = Some("disabled".to_string());
36598        assert!(!apply(&form).analysis.count_compiler_directives);
36599    }
36600
36601    // ── mixed_line_policy (enum select) ──
36602
36603    #[test]
36604    fn mixed_policy_unchanged_when_absent() {
36605        // None → if-let does nothing → stays at config default (CodeOnly)
36606        assert_eq!(
36607            apply(&blank_form()).analysis.mixed_line_policy,
36608            MixedLinePolicy::CodeOnly
36609        );
36610    }
36611
36612    #[test]
36613    fn mixed_policy_code_only() {
36614        let mut form = blank_form();
36615        form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
36616        assert_eq!(
36617            apply(&form).analysis.mixed_line_policy,
36618            MixedLinePolicy::CodeOnly
36619        );
36620    }
36621
36622    #[test]
36623    fn mixed_policy_code_and_comment() {
36624        let mut form = blank_form();
36625        form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
36626        assert_eq!(
36627            apply(&form).analysis.mixed_line_policy,
36628            MixedLinePolicy::CodeAndComment
36629        );
36630    }
36631
36632    #[test]
36633    fn mixed_policy_comment_only() {
36634        let mut form = blank_form();
36635        form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
36636        assert_eq!(
36637            apply(&form).analysis.mixed_line_policy,
36638            MixedLinePolicy::CommentOnly
36639        );
36640    }
36641
36642    #[test]
36643    fn mixed_policy_separate_mixed_category() {
36644        let mut form = blank_form();
36645        form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
36646        assert_eq!(
36647            apply(&form).analysis.mixed_line_policy,
36648            MixedLinePolicy::SeparateMixedCategory
36649        );
36650    }
36651
36652    // ── binary_file_behavior (enum select) ──
36653
36654    #[test]
36655    fn binary_behavior_skip_when_absent() {
36656        assert_eq!(
36657            apply(&blank_form()).analysis.binary_file_behavior,
36658            BinaryFileBehavior::Skip
36659        );
36660    }
36661
36662    #[test]
36663    fn binary_behavior_skip() {
36664        let mut form = blank_form();
36665        form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
36666        assert_eq!(
36667            apply(&form).analysis.binary_file_behavior,
36668            BinaryFileBehavior::Skip
36669        );
36670    }
36671
36672    #[test]
36673    fn binary_behavior_fail() {
36674        let mut form = blank_form();
36675        form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
36676        assert_eq!(
36677            apply(&form).analysis.binary_file_behavior,
36678            BinaryFileBehavior::Fail
36679        );
36680    }
36681
36682    // ── continuation_line_policy (enum select) ──
36683
36684    #[test]
36685    fn continuation_policy_each_physical_when_absent() {
36686        assert_eq!(
36687            apply(&blank_form()).analysis.continuation_line_policy,
36688            ContinuationLinePolicy::EachPhysicalLine
36689        );
36690    }
36691
36692    #[test]
36693    fn continuation_policy_collapse_to_logical() {
36694        let mut form = blank_form();
36695        form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
36696        assert_eq!(
36697            apply(&form).analysis.continuation_line_policy,
36698            ContinuationLinePolicy::CollapseToLogical
36699        );
36700    }
36701
36702    // ── blank_in_block_comment_policy (enum select) ──
36703
36704    #[test]
36705    fn blank_in_block_comment_count_as_comment_when_absent() {
36706        assert_eq!(
36707            apply(&blank_form()).analysis.blank_in_block_comment_policy,
36708            BlankInBlockCommentPolicy::CountAsComment
36709        );
36710    }
36711
36712    #[test]
36713    fn blank_in_block_comment_count_as_blank() {
36714        let mut form = blank_form();
36715        form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
36716        assert_eq!(
36717            apply(&form).analysis.blank_in_block_comment_policy,
36718            BlankInBlockCommentPolicy::CountAsBlank
36719        );
36720    }
36721
36722    // ── style_col_threshold ──
36723
36724    #[test]
36725    fn style_threshold_80() {
36726        let mut form = blank_form();
36727        form.style_col_threshold = Some("80".to_string());
36728        assert_eq!(apply(&form).analysis.style_col_threshold, 80);
36729    }
36730
36731    #[test]
36732    fn style_threshold_100() {
36733        let mut form = blank_form();
36734        form.style_col_threshold = Some("100".to_string());
36735        assert_eq!(apply(&form).analysis.style_col_threshold, 100);
36736    }
36737
36738    #[test]
36739    fn style_threshold_120() {
36740        let mut form = blank_form();
36741        form.style_col_threshold = Some("120".to_string());
36742        assert_eq!(apply(&form).analysis.style_col_threshold, 120);
36743    }
36744
36745    #[test]
36746    fn style_threshold_invalid_value_leaves_default() {
36747        // 42 is not in the allowed set {80, 100, 120} — must be ignored.
36748        let mut cfg = sloc_config::AppConfig::default();
36749        let mut form = blank_form();
36750        form.style_col_threshold = Some("42".to_string());
36751        apply_form_to_config(&mut cfg, &form);
36752        assert_eq!(
36753            cfg.analysis.style_col_threshold, 80,
36754            "invalid threshold must not change config"
36755        );
36756    }
36757
36758    #[test]
36759    fn style_threshold_non_numeric_leaves_default() {
36760        let mut cfg = sloc_config::AppConfig::default();
36761        let mut form = blank_form();
36762        form.style_col_threshold = Some("large".to_string());
36763        apply_form_to_config(&mut cfg, &form);
36764        assert_eq!(cfg.analysis.style_col_threshold, 80);
36765    }
36766
36767    #[test]
36768    fn style_threshold_zero_leaves_default() {
36769        let mut cfg = sloc_config::AppConfig::default();
36770        let mut form = blank_form();
36771        form.style_col_threshold = Some("0".to_string());
36772        apply_form_to_config(&mut cfg, &form);
36773        assert_eq!(cfg.analysis.style_col_threshold, 80);
36774    }
36775
36776    #[test]
36777    fn style_threshold_absent_leaves_default() {
36778        assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
36779    }
36780
36781    // ── style_score_threshold ──
36782
36783    #[test]
36784    fn style_score_threshold_zero_when_absent() {
36785        assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
36786    }
36787
36788    #[test]
36789    fn style_score_threshold_set_to_valid_value() {
36790        let mut form = blank_form();
36791        form.style_score_threshold = Some("70".to_string());
36792        assert_eq!(apply(&form).analysis.style_score_threshold, 70);
36793    }
36794
36795    #[test]
36796    fn style_score_threshold_clamps_to_100_when_over() {
36797        // t.min(100) must cap any value > 100 (e.g. from a crafted POST body).
36798        let mut form = blank_form();
36799        form.style_score_threshold = Some("200".to_string());
36800        assert_eq!(
36801            apply(&form).analysis.style_score_threshold,
36802            100,
36803            "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
36804        );
36805    }
36806
36807    // ── coverage_file ──
36808
36809    #[test]
36810    fn coverage_file_none_when_absent() {
36811        assert!(apply(&blank_form()).analysis.coverage_file.is_none());
36812    }
36813
36814    #[test]
36815    fn coverage_file_none_when_whitespace_only() {
36816        let mut form = blank_form();
36817        form.coverage_file = Some("   ".to_string());
36818        assert!(
36819            apply(&form).analysis.coverage_file.is_none(),
36820            "whitespace-only coverage_file must be treated as None"
36821        );
36822    }
36823
36824    #[test]
36825    fn coverage_file_set_when_non_empty() {
36826        let mut form = blank_form();
36827        form.coverage_file = Some("coverage/lcov.info".to_string());
36828        assert_eq!(
36829            apply(&form).analysis.coverage_file,
36830            Some(std::path::PathBuf::from("coverage/lcov.info"))
36831        );
36832    }
36833
36834    #[test]
36835    fn coverage_file_trims_whitespace() {
36836        let mut form = blank_form();
36837        form.coverage_file = Some("  coverage/lcov.info  ".to_string());
36838        assert_eq!(
36839            apply(&form).analysis.coverage_file,
36840            Some(std::path::PathBuf::from("coverage/lcov.info"))
36841        );
36842    }
36843
36844    // ── report_title ──
36845
36846    #[test]
36847    fn report_title_unchanged_when_absent() {
36848        let original = sloc_config::AppConfig::default().reporting.report_title;
36849        assert_eq!(apply(&blank_form()).reporting.report_title, original);
36850    }
36851
36852    #[test]
36853    fn report_title_unchanged_when_whitespace_only() {
36854        let original = sloc_config::AppConfig::default().reporting.report_title;
36855        let mut form = blank_form();
36856        form.report_title = Some("   ".to_string());
36857        assert_eq!(
36858            apply(&form).reporting.report_title,
36859            original,
36860            "whitespace-only title must not overwrite the default"
36861        );
36862    }
36863
36864    #[test]
36865    fn report_title_updated_and_trimmed() {
36866        let mut form = blank_form();
36867        form.report_title = Some("  My Project  ".to_string());
36868        assert_eq!(apply(&form).reporting.report_title, "My Project");
36869    }
36870
36871    // ── report_header_footer ──
36872
36873    #[test]
36874    fn header_footer_none_when_absent() {
36875        assert!(
36876            apply(&blank_form())
36877                .reporting
36878                .report_header_footer
36879                .is_none()
36880        );
36881    }
36882
36883    #[test]
36884    fn header_footer_none_when_whitespace_only() {
36885        let mut form = blank_form();
36886        form.report_header_footer = Some("  ".to_string());
36887        assert!(apply(&form).reporting.report_header_footer.is_none());
36888    }
36889
36890    #[test]
36891    fn header_footer_set_and_trimmed() {
36892        let mut form = blank_form();
36893        form.report_header_footer = Some("  Confidential — Internal Use  ".to_string());
36894        assert_eq!(
36895            apply(&form).reporting.report_header_footer,
36896            Some("Confidential — Internal Use".to_string())
36897        );
36898    }
36899
36900    // ── include_globs / exclude_globs ──
36901
36902    #[test]
36903    fn include_globs_empty_when_absent() {
36904        assert!(apply(&blank_form()).discovery.include_globs.is_empty());
36905    }
36906
36907    #[test]
36908    fn include_globs_newline_separated() {
36909        let mut form = blank_form();
36910        form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
36911        assert_eq!(
36912            apply(&form).discovery.include_globs,
36913            vec!["src/**/*.rs", "tests/**/*.rs"]
36914        );
36915    }
36916
36917    #[test]
36918    fn exclude_globs_comma_separated() {
36919        let mut form = blank_form();
36920        form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
36921        assert_eq!(
36922            apply(&form).discovery.exclude_globs,
36923            vec!["vendor/**", "node_modules/**"]
36924        );
36925    }
36926
36927    #[test]
36928    fn globs_mixed_separators() {
36929        let mut form = blank_form();
36930        form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
36931        assert_eq!(
36932            apply(&form).discovery.exclude_globs,
36933            vec!["a/**", "b/**", "c/**"]
36934        );
36935    }
36936
36937    // ── split_patterns unit tests ──
36938
36939    #[test]
36940    fn split_patterns_none_is_empty() {
36941        assert!(split_patterns(None).is_empty());
36942    }
36943
36944    #[test]
36945    fn split_patterns_empty_string_is_empty() {
36946        assert!(split_patterns(Some("")).is_empty());
36947    }
36948
36949    #[test]
36950    fn split_patterns_whitespace_only_is_empty() {
36951        assert!(split_patterns(Some("  \n  \n  ")).is_empty());
36952    }
36953
36954    #[test]
36955    fn split_patterns_newlines() {
36956        assert_eq!(
36957            split_patterns(Some("a/**\nb/**\nc/**")),
36958            vec!["a/**", "b/**", "c/**"]
36959        );
36960    }
36961
36962    #[test]
36963    fn split_patterns_commas() {
36964        assert_eq!(
36965            split_patterns(Some("a/**,b/**,c/**")),
36966            vec!["a/**", "b/**", "c/**"]
36967        );
36968    }
36969
36970    #[test]
36971    fn split_patterns_mixed() {
36972        assert_eq!(
36973            split_patterns(Some("a/**\nb/**,c/**")),
36974            vec!["a/**", "b/**", "c/**"]
36975        );
36976    }
36977
36978    #[test]
36979    fn split_patterns_trims_whitespace() {
36980        assert_eq!(
36981            split_patterns(Some("  a/**  \n  b/**  ")),
36982            vec!["a/**", "b/**"]
36983        );
36984    }
36985
36986    #[test]
36987    fn split_patterns_filters_empty_entries() {
36988        assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
36989    }
36990
36991    #[test]
36992    fn split_patterns_single_entry() {
36993        assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
36994    }
36995}
36996
36997#[cfg(test)]
36998mod utility_tests {
36999    use super::*;
37000    use std::net::IpAddr;
37001    use std::time::Duration;
37002
37003    // ── sanitize_project_label ────────────────────────────────────────────────
37004
37005    #[test]
37006    fn sanitize_simple_name() {
37007        assert_eq!(sanitize_project_label("myrepo"), "myrepo");
37008    }
37009
37010    #[test]
37011    fn sanitize_uppercased_lowercased() {
37012        assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
37013    }
37014
37015    #[test]
37016    fn sanitize_path_extracts_filename() {
37017        assert_eq!(
37018            sanitize_project_label("/home/user/my-project"),
37019            "my-project"
37020        );
37021    }
37022
37023    #[test]
37024    fn sanitize_path_uses_last_component() {
37025        assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
37026    }
37027
37028    #[test]
37029    fn sanitize_spaces_become_hyphens() {
37030        assert_eq!(sanitize_project_label("my project"), "my-project");
37031    }
37032
37033    #[test]
37034    fn sanitize_non_ascii_become_hyphens() {
37035        assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
37036    }
37037
37038    #[test]
37039    fn sanitize_all_special_chars_gives_project() {
37040        assert_eq!(sanitize_project_label("!@#$%^"), "project");
37041    }
37042
37043    #[test]
37044    fn sanitize_empty_string_gives_project() {
37045        assert_eq!(sanitize_project_label(""), "project");
37046    }
37047
37048    #[test]
37049    fn sanitize_leading_trailing_hyphens_stripped() {
37050        assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
37051    }
37052
37053    #[test]
37054    fn sanitize_alphanumeric_preserved() {
37055        assert_eq!(sanitize_project_label("repo123"), "repo123");
37056    }
37057
37058    #[test]
37059    fn sanitize_dots_become_hyphens() {
37060        assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
37061    }
37062
37063    #[test]
37064    fn sanitize_mixed_slashes_uses_filename() {
37065        // The Windows path separator — on all platforms Path::file_name still works
37066        assert_eq!(sanitize_project_label("project-name"), "project-name");
37067    }
37068
37069    // ── IpRateLimiter ─────────────────────────────────────────────────────────
37070
37071    #[test]
37072    fn rate_limiter_allows_first_request() {
37073        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
37074        let ip: IpAddr = "127.0.0.1".parse().unwrap();
37075        assert!(rl.is_allowed(ip));
37076    }
37077
37078    #[test]
37079    fn rate_limiter_blocks_after_limit_reached() {
37080        let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
37081        let ip: IpAddr = "10.0.0.1".parse().unwrap();
37082        assert!(rl.is_allowed(ip));
37083        assert!(rl.is_allowed(ip));
37084        assert!(rl.is_allowed(ip));
37085        assert!(!rl.is_allowed(ip), "4th request must be blocked");
37086    }
37087
37088    #[test]
37089    fn rate_limiter_allows_requests_up_to_limit() {
37090        let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
37091        let ip: IpAddr = "10.0.0.2".parse().unwrap();
37092        for _ in 0..5 {
37093            assert!(rl.is_allowed(ip));
37094        }
37095        assert!(!rl.is_allowed(ip), "6th request must be blocked");
37096    }
37097
37098    #[test]
37099    fn rate_limiter_different_ips_are_independent() {
37100        let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
37101        let ip1: IpAddr = "192.168.1.1".parse().unwrap();
37102        let ip2: IpAddr = "192.168.1.2".parse().unwrap();
37103        assert!(rl.is_allowed(ip1));
37104        assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
37105        assert!(rl.is_allowed(ip2), "ip2 must be independent");
37106    }
37107
37108    #[test]
37109    fn rate_limiter_auth_failure_not_locked_below_threshold() {
37110        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
37111        let ip: IpAddr = "10.0.0.3".parse().unwrap();
37112        rl.record_auth_failure(ip);
37113        rl.record_auth_failure(ip);
37114        assert!(
37115            !rl.is_auth_locked_out(ip),
37116            "not locked at 2 failures when threshold is 3"
37117        );
37118    }
37119
37120    #[test]
37121    fn rate_limiter_auth_failure_locked_at_threshold() {
37122        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
37123        let ip: IpAddr = "10.0.0.4".parse().unwrap();
37124        rl.record_auth_failure(ip);
37125        rl.record_auth_failure(ip);
37126        rl.record_auth_failure(ip);
37127        assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
37128    }
37129
37130    #[test]
37131    fn rate_limiter_auth_failure_different_ips_independent() {
37132        let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
37133        let ip1: IpAddr = "10.0.1.1".parse().unwrap();
37134        let ip2: IpAddr = "10.0.1.2".parse().unwrap();
37135        rl.record_auth_failure(ip1);
37136        rl.record_auth_failure(ip1);
37137        assert!(rl.is_auth_locked_out(ip1));
37138        assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
37139    }
37140
37141    #[test]
37142    fn rate_limiter_high_limit_never_blocks_normal_traffic() {
37143        let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
37144        let ip: IpAddr = "127.0.0.2".parse().unwrap();
37145        for _ in 0..100 {
37146            assert!(rl.is_allowed(ip));
37147        }
37148    }
37149
37150    // ── strip_unc_prefix ──────────────────────────────────────────────────────
37151
37152    #[test]
37153    fn strip_unc_plain_path_unchanged() {
37154        let p = PathBuf::from("C:\\Users\\user\\project");
37155        let result = strip_unc_prefix(p.clone());
37156        assert_eq!(result, p);
37157    }
37158
37159    #[test]
37160    fn strip_unc_with_drive_prefix_stripped() {
37161        let p = PathBuf::from(r"\\?\C:\Users\user\project");
37162        let result = strip_unc_prefix(p);
37163        assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
37164    }
37165
37166    #[test]
37167    fn strip_unc_with_network_prefix_stripped() {
37168        let p = PathBuf::from(r"\\?\UNC\server\share\dir");
37169        let result = strip_unc_prefix(p);
37170        assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
37171    }
37172
37173    #[test]
37174    fn strip_unc_linux_path_unchanged() {
37175        let p = PathBuf::from("/home/user/project");
37176        let result = strip_unc_prefix(p.clone());
37177        assert_eq!(result, p);
37178    }
37179
37180    // ── remote_to_commit_url ──────────────────────────────────────────────────
37181
37182    #[test]
37183    fn remote_to_commit_url_github_https() {
37184        let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
37185        assert_eq!(
37186            url,
37187            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
37188        );
37189    }
37190
37191    #[test]
37192    fn remote_to_commit_url_github_ssh() {
37193        let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
37194        assert_eq!(
37195            url,
37196            Some("https://github.com/owner/repo/commit/abc1234".to_owned())
37197        );
37198    }
37199
37200    #[test]
37201    fn remote_to_commit_url_gitlab_uses_dash_commit() {
37202        let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
37203        assert_eq!(
37204            url,
37205            Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
37206        );
37207    }
37208
37209    #[test]
37210    fn remote_to_commit_url_bitbucket_uses_commits() {
37211        let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
37212        assert_eq!(
37213            url,
37214            Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
37215        );
37216    }
37217
37218    #[test]
37219    fn remote_to_commit_url_unknown_scheme_returns_none() {
37220        let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
37221        assert!(url.is_none());
37222    }
37223
37224    #[test]
37225    fn remote_to_commit_url_ssh_gitlab() {
37226        let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
37227        assert!(url.is_some());
37228        let u = url.unwrap();
37229        assert!(
37230            u.contains("/-/commit/sha123"),
37231            "gitlab ssh must use /-/commit/"
37232        );
37233    }
37234
37235    // ── git_clone_dest ────────────────────────────────────────────────────────
37236
37237    #[test]
37238    fn git_clone_dest_github_url_produces_safe_name() {
37239        let dir = PathBuf::from("/tmp/clones");
37240        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
37241        let name = dest.file_name().unwrap().to_string_lossy();
37242        assert!(!name.is_empty());
37243        assert!(
37244            name.chars()
37245                .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
37246            "clone dest must only contain safe chars, got: {name}"
37247        );
37248    }
37249
37250    #[test]
37251    fn git_clone_dest_is_inside_clones_dir() {
37252        let dir = PathBuf::from("/tmp/clones");
37253        let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
37254        assert!(
37255            dest.starts_with(&dir),
37256            "clone dest must be inside clones_dir"
37257        );
37258    }
37259
37260    #[test]
37261    fn git_clone_dest_truncates_to_80_chars_max() {
37262        let long_url = "https://github.com/".to_string() + &"a".repeat(200);
37263        let dir = PathBuf::from("/tmp/clones");
37264        let dest = git_clone_dest(&long_url, &dir);
37265        let name = dest.file_name().unwrap().to_string_lossy();
37266        assert!(
37267            name.len() <= 80,
37268            "clone dest name must be at most 80 chars, got {} chars: {name}",
37269            name.len()
37270        );
37271    }
37272
37273    #[test]
37274    fn git_clone_dest_special_chars_replaced_with_underscore() {
37275        let dir = PathBuf::from("/tmp/clones");
37276        let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
37277        let name = dest.file_name().unwrap().to_string_lossy();
37278        assert!(
37279            !name.contains('@') && !name.contains(':') && !name.contains('/'),
37280            "special chars must be replaced in clone dest, got: {name}"
37281        );
37282    }
37283
37284    #[test]
37285    fn git_clone_dest_different_urls_differ() {
37286        let dir = PathBuf::from("/tmp/clones");
37287        let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
37288        let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
37289        assert_ne!(
37290            a, b,
37291            "different repos must produce different clone dest names"
37292        );
37293    }
37294
37295    #[test]
37296    fn git_clone_dest_same_url_same_result() {
37297        let dir = PathBuf::from("/tmp/clones");
37298        let url = "https://github.com/owner/repo.git";
37299        assert_eq!(
37300            git_clone_dest(url, &dir),
37301            git_clone_dest(url, &dir),
37302            "same URL must always give same clone dest"
37303        );
37304    }
37305
37306    // ── fmt_delta ─────────────────────────────────────────────────────────────
37307
37308    #[test]
37309    fn fmt_delta_positive_has_plus_prefix() {
37310        assert_eq!(fmt_delta(5), "+5");
37311    }
37312
37313    #[test]
37314    fn fmt_delta_negative_no_plus_prefix() {
37315        assert_eq!(fmt_delta(-3), "-3");
37316    }
37317
37318    #[test]
37319    fn fmt_delta_zero() {
37320        assert_eq!(fmt_delta(0), "0");
37321    }
37322
37323    // ── delta_class ───────────────────────────────────────────────────────────
37324
37325    #[test]
37326    fn delta_class_positive_is_pos() {
37327        assert_eq!(delta_class(1), "pos");
37328    }
37329
37330    #[test]
37331    fn delta_class_negative_is_neg() {
37332        assert_eq!(delta_class(-1), "neg");
37333    }
37334
37335    #[test]
37336    fn delta_class_zero_is_zero_class() {
37337        assert_eq!(delta_class(0), "zero");
37338    }
37339
37340    // ── fmt_pct ───────────────────────────────────────────────────────────────
37341
37342    #[test]
37343    fn fmt_pct_zero_baseline_returns_em_dash() {
37344        assert_eq!(fmt_pct(100, 0), "\u{2014}");
37345    }
37346
37347    #[test]
37348    fn fmt_pct_positive_delta_has_plus_sign() {
37349        let result = fmt_pct(10, 100);
37350        assert!(result.starts_with('+'), "expected + prefix, got: {result}");
37351    }
37352
37353    #[test]
37354    fn fmt_pct_negative_delta_no_plus_sign() {
37355        let result = fmt_pct(-10, 100);
37356        assert!(!result.starts_with('+'), "unexpected + in: {result}");
37357        assert!(result.contains('%'));
37358    }
37359
37360    #[test]
37361    fn fmt_pct_near_zero_returns_pm_zero() {
37362        assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
37363    }
37364
37365    // ── summary_delta ─────────────────────────────────────────────────────────
37366
37367    #[test]
37368    fn summary_delta_no_prev_returns_dash_na() {
37369        let (display, class) = summary_delta(10, None);
37370        assert_eq!(display, "\u{2014}");
37371        assert_eq!(class, "na");
37372    }
37373
37374    #[test]
37375    fn summary_delta_increase_is_positive() {
37376        let (display, class) = summary_delta(15, Some(10));
37377        assert_eq!(display, "+5");
37378        assert_eq!(class, "pos");
37379    }
37380
37381    #[test]
37382    fn summary_delta_decrease_is_negative() {
37383        let (display, class) = summary_delta(5, Some(10));
37384        assert_eq!(display, "-5");
37385        assert_eq!(class, "neg");
37386    }
37387
37388    // ── nth_weekday_of_month ──────────────────────────────────────────────────
37389
37390    #[test]
37391    fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
37392        use chrono::Datelike;
37393        let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
37394        assert_eq!(d.year(), 2024);
37395        assert_eq!(d.month(), 1);
37396        assert_eq!(d.weekday(), chrono::Weekday::Mon);
37397        assert!(d.day() <= 7);
37398    }
37399
37400    #[test]
37401    fn nth_weekday_second_sunday_march_2024_is_10th() {
37402        use chrono::Datelike;
37403        let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
37404        assert_eq!(d.weekday(), chrono::Weekday::Sun);
37405        assert_eq!(d.month(), 3);
37406        assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
37407    }
37408
37409    // ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
37410
37411    #[test]
37412    fn is_pacific_dst_july_is_true() {
37413        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
37414        assert!(is_pacific_dst(dt), "July must be PDT");
37415    }
37416
37417    #[test]
37418    fn is_pacific_dst_january_is_false() {
37419        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
37420        assert!(!is_pacific_dst(dt), "January must be PST");
37421    }
37422
37423    #[test]
37424    fn fmt_la_time_summer_shows_pdt() {
37425        let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
37426        let result = fmt_la_time(dt);
37427        assert!(
37428            result.ends_with("PDT"),
37429            "summer must use PDT, got: {result}"
37430        );
37431    }
37432
37433    #[test]
37434    fn fmt_la_time_winter_shows_pst() {
37435        let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
37436        let result = fmt_la_time(dt);
37437        assert!(
37438            result.ends_with("PST"),
37439            "winter must use PST, got: {result}"
37440        );
37441    }
37442
37443    #[test]
37444    fn fmt_la_time_meta_summer_shows_pdt() {
37445        let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
37446        let result = fmt_la_time_meta(dt);
37447        assert!(
37448            result.ends_with("PDT"),
37449            "meta summer must use PDT, got: {result}"
37450        );
37451    }
37452
37453    #[test]
37454    fn fmt_la_time_meta_winter_shows_pst() {
37455        let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
37456        let result = fmt_la_time_meta(dt);
37457        assert!(
37458            result.ends_with("PST"),
37459            "meta winter must use PST, got: {result}"
37460        );
37461    }
37462
37463    // ── fmt_git_date ──────────────────────────────────────────────────────────
37464
37465    #[test]
37466    fn fmt_git_date_valid_iso_returns_some() {
37467        assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
37468    }
37469
37470    #[test]
37471    fn fmt_git_date_invalid_returns_none() {
37472        assert!(fmt_git_date("not-a-date").is_none());
37473    }
37474
37475    // ── format_number ─────────────────────────────────────────────────────────
37476
37477    #[test]
37478    fn format_number_zero() {
37479        assert_eq!(format_number(0), "0");
37480    }
37481
37482    #[test]
37483    fn format_number_three_digits_no_comma() {
37484        assert_eq!(format_number(999), "999");
37485    }
37486
37487    #[test]
37488    fn format_number_four_digits_has_comma() {
37489        assert_eq!(format_number(1000), "1,000");
37490    }
37491
37492    #[test]
37493    fn format_number_seven_digits_two_commas() {
37494        assert_eq!(format_number(1_234_567), "1,234,567");
37495    }
37496
37497    #[test]
37498    fn format_number_one_million() {
37499        assert_eq!(format_number(1_000_000), "1,000,000");
37500    }
37501
37502    // ── badge_text_px / render_badge_svg ──────────────────────────────────────
37503
37504    #[test]
37505    fn badge_text_px_empty_is_zero() {
37506        assert_eq!(badge_text_px(""), 0);
37507    }
37508
37509    #[test]
37510    fn badge_text_px_narrow_chars_smaller_than_normal() {
37511        assert!(
37512            badge_text_px("if") < badge_text_px("ab"),
37513            "'if' must be narrower than 'ab'"
37514        );
37515    }
37516
37517    #[test]
37518    fn badge_text_px_m_is_wider_than_a() {
37519        assert!(
37520            badge_text_px("m") > badge_text_px("a"),
37521            "'m' must be wider than 'a'"
37522        );
37523    }
37524
37525    #[test]
37526    fn render_badge_svg_contains_label_and_value() {
37527        let svg = render_badge_svg("coverage", "95%", "#4c1");
37528        assert!(svg.contains("coverage") && svg.contains("95%"));
37529    }
37530
37531    #[test]
37532    fn render_badge_svg_contains_color() {
37533        let svg = render_badge_svg("sloc", "12K", "#e05d44");
37534        assert!(svg.contains("#e05d44"), "SVG must contain fill color");
37535    }
37536
37537    #[test]
37538    fn render_badge_svg_escapes_ampersand_in_label() {
37539        let svg = render_badge_svg("test&label", "ok", "#4c1");
37540        assert!(svg.contains("&amp;") && !svg.contains("test&label"));
37541    }
37542
37543    // ── build_pdf_filename ────────────────────────────────────────────────────
37544
37545    #[test]
37546    fn build_pdf_filename_slugifies_title() {
37547        let name = build_pdf_filename("My Project Report", "abc-def-1234");
37548        assert!(
37549            name.starts_with("my_project_report_")
37550                && std::path::Path::new(&name)
37551                    .extension()
37552                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
37553        );
37554    }
37555
37556    #[test]
37557    fn build_pdf_filename_uses_last_run_id_segment() {
37558        let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
37559        assert!(name.contains("ABCD"), "must use last segment of run_id");
37560    }
37561
37562    #[test]
37563    fn build_pdf_filename_empty_title_uses_report_prefix() {
37564        let name = build_pdf_filename("", "abc-def-9999");
37565        assert!(
37566            name.starts_with("report_")
37567                && std::path::Path::new(&name)
37568                    .extension()
37569                    .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
37570        );
37571    }
37572
37573    // ── swap_inline_chart_js_for_static ───────────────────────────────────────
37574
37575    #[test]
37576    fn swap_chart_js_replaces_inline_block() {
37577        let html = "<html><head><script>// inline source</script></head><body></body></html>";
37578        let result = swap_inline_chart_js_for_static(html.to_string());
37579        assert!(result.contains(r#"src="/static/chart-report.js""#));
37580        assert!(!result.contains("inline source"));
37581    }
37582
37583    #[test]
37584    fn swap_chart_js_no_head_returns_unchanged() {
37585        let html = "<body>no head here</body>";
37586        assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
37587    }
37588
37589    #[test]
37590    fn swap_chart_js_no_script_in_head_unchanged() {
37591        let html = "<html><head><style>.x{}</style></head><body></body></html>";
37592        let result = swap_inline_chart_js_for_static(html.to_string());
37593        assert!(!result.contains("chart-report.js"));
37594    }
37595
37596    // ── patch_html_nonce ──────────────────────────────────────────────────────
37597
37598    #[test]
37599    fn patch_html_nonce_replaces_old_nonce() {
37600        let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
37601        let result = patch_html_nonce(html, "new-nonce-456");
37602        assert!(result.contains(r#"nonce="new-nonce-456""#));
37603        assert!(!result.contains("old-nonce-123"));
37604    }
37605
37606    #[test]
37607    fn patch_html_nonce_injects_into_bare_style() {
37608        let html = "<style>body{color:red;}</style>";
37609        let result = patch_html_nonce(html, "fresh-nonce");
37610        assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
37611    }
37612
37613    #[test]
37614    fn patch_html_nonce_injects_into_bare_script() {
37615        let html = "<script>console.log(1);</script>";
37616        let result = patch_html_nonce(html, "abc");
37617        assert!(result.contains(r#"<script nonce="abc">"#));
37618    }
37619
37620    // ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
37621
37622    #[test]
37623    fn is_html_report_file_result_html_matches() {
37624        let dir = tempfile::tempdir().unwrap();
37625        let path = dir.path().join("result_20240101.html");
37626        std::fs::write(&path, b"<html></html>").unwrap();
37627        assert!(is_html_report_file(&path));
37628    }
37629
37630    #[test]
37631    fn is_html_report_file_report_html_matches() {
37632        let dir = tempfile::tempdir().unwrap();
37633        let path = dir.path().join("report_abc.html");
37634        std::fs::write(&path, b"<html></html>").unwrap();
37635        assert!(is_html_report_file(&path));
37636    }
37637
37638    #[test]
37639    fn is_html_report_file_index_html_does_not_match() {
37640        let dir = tempfile::tempdir().unwrap();
37641        let path = dir.path().join("index.html");
37642        std::fs::write(&path, b"<html></html>").unwrap();
37643        assert!(!is_html_report_file(&path));
37644    }
37645
37646    #[test]
37647    fn is_html_report_file_nonexistent_returns_false() {
37648        assert!(!is_html_report_file(Path::new(
37649            "/nonexistent/result_xyz.html"
37650        )));
37651    }
37652
37653    #[test]
37654    fn find_html_report_in_dir_finds_result_html() {
37655        let dir = tempfile::tempdir().unwrap();
37656        std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
37657        assert!(find_html_report_in_dir(dir.path()).is_some());
37658    }
37659
37660    #[test]
37661    fn find_html_report_in_dir_empty_returns_none() {
37662        let dir = tempfile::tempdir().unwrap();
37663        assert!(find_html_report_in_dir(dir.path()).is_none());
37664    }
37665
37666    #[test]
37667    fn find_html_report_in_tree_finds_in_subdir() {
37668        let dir = tempfile::tempdir().unwrap();
37669        let subdir = dir.path().join("run-001");
37670        std::fs::create_dir_all(&subdir).unwrap();
37671        std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
37672        assert!(find_html_report_in_tree(dir.path()).is_some());
37673    }
37674
37675    // ── derive_project_label ──────────────────────────────────────────────────
37676
37677    #[test]
37678    fn derive_project_label_with_git_repo_and_ref() {
37679        let label = derive_project_label(
37680            Some("https://github.com/owner/my-repo.git"),
37681            Some("main"),
37682            "/fallback/path",
37683        );
37684        assert!(!label.is_empty(), "label must not be empty");
37685        assert!(
37686            label.contains("my") || label.contains("repo"),
37687            "got: {label}"
37688        );
37689    }
37690
37691    #[test]
37692    fn derive_project_label_fallback_to_path() {
37693        let label = derive_project_label(None, None, "/path/to/myproject");
37694        assert_eq!(label, "myproject");
37695    }
37696
37697    #[test]
37698    fn derive_project_label_empty_git_fields_use_path() {
37699        let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
37700        assert_eq!(label, "cool-app");
37701    }
37702
37703    // ── derive_file_stem ──────────────────────────────────────────────────────
37704
37705    #[test]
37706    fn derive_file_stem_with_commit_appends_sha() {
37707        assert_eq!(
37708            derive_file_stem("myproject", Some("a1b2c3")),
37709            "myproject_a1b2c3"
37710        );
37711    }
37712
37713    #[test]
37714    fn derive_file_stem_without_commit_returns_label() {
37715        assert_eq!(derive_file_stem("myproject", None), "myproject");
37716    }
37717
37718    #[test]
37719    fn derive_file_stem_empty_commit_returns_label() {
37720        assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
37721    }
37722
37723    #[test]
37724    fn derive_run_dir_name_trims_uuid_and_folds_branch() {
37725        let name = derive_run_dir_name(
37726            "myproject",
37727            Some("main"),
37728            "20260827-1234-abcdef0123456789abcdef0123456789",
37729        );
37730        assert_eq!(name, "myproject-main-20260827-1234-abcdef01");
37731    }
37732
37733    #[test]
37734    fn derive_run_dir_name_without_branch_omits_segment() {
37735        let name = derive_run_dir_name(
37736            "myproject",
37737            None,
37738            "20260827-1234-abcdef0123456789abcdef0123456789",
37739        );
37740        assert_eq!(name, "myproject-20260827-1234-abcdef01");
37741    }
37742
37743    #[test]
37744    fn derive_run_dir_name_skips_branch_already_in_label() {
37745        // Git-remote labels already encode the ref (e.g. "repo_main"); don't duplicate it.
37746        let name = derive_run_dir_name(
37747            "repo-main",
37748            Some("main"),
37749            "20260827-1234-abcdef0123456789abcdef0123456789",
37750        );
37751        assert_eq!(name, "repo-main-20260827-1234-abcdef01");
37752    }
37753
37754    #[test]
37755    fn derive_run_dir_name_passes_through_unexpected_run_id() {
37756        // A run_id that isn't the "stamp-uuid" shape is left intact (no hex tail to trim).
37757        let name = derive_run_dir_name("proj", None, "custom-run");
37758        assert_eq!(name, "proj-custom-run");
37759    }
37760
37761    #[test]
37762    fn derive_run_dir_name_sanitizes_branch_with_slash() {
37763        let name = derive_run_dir_name(
37764            "proj",
37765            Some("feature/new-ui"),
37766            "20260827-1234-abcdef0123456789abcdef0123456789",
37767        );
37768        assert_eq!(name, "proj-new-ui-20260827-1234-abcdef01");
37769    }
37770
37771    // ── split_patterns ────────────────────────────────────────────────────────
37772
37773    #[test]
37774    fn split_patterns_none_is_empty() {
37775        assert!(split_patterns(None).is_empty());
37776    }
37777
37778    #[test]
37779    fn split_patterns_empty_string_is_empty() {
37780        assert!(split_patterns(Some("")).is_empty());
37781    }
37782
37783    #[test]
37784    fn split_patterns_comma_separated() {
37785        assert_eq!(
37786            split_patterns(Some("foo,bar,baz")),
37787            vec!["foo", "bar", "baz"]
37788        );
37789    }
37790
37791    #[test]
37792    fn split_patterns_newline_separated() {
37793        assert_eq!(
37794            split_patterns(Some("foo\nbar\nbaz")),
37795            vec!["foo", "bar", "baz"]
37796        );
37797    }
37798
37799    #[test]
37800    fn split_patterns_trims_whitespace() {
37801        assert_eq!(split_patterns(Some("  foo  ,  bar  ")), vec!["foo", "bar"]);
37802    }
37803
37804    // ── make_git_label ────────────────────────────────────────────────────────
37805
37806    #[test]
37807    fn make_git_label_empty_repo_empty_result() {
37808        assert_eq!(make_git_label("", "main"), "");
37809    }
37810
37811    #[test]
37812    fn make_git_label_empty_ref_empty_result() {
37813        assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
37814    }
37815
37816    #[test]
37817    fn make_git_label_basic_format() {
37818        assert_eq!(
37819            make_git_label("https://github.com/owner/my-repo.git", "main"),
37820            "my-repo_at_main_sloc"
37821        );
37822    }
37823
37824    #[test]
37825    fn make_git_label_slash_in_ref_replaced() {
37826        let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
37827        assert!(
37828            !label.contains('/'),
37829            "slash in ref must be replaced: {label}"
37830        );
37831    }
37832
37833    // ── format_dir_size ───────────────────────────────────────────────────────
37834
37835    #[test]
37836    fn format_dir_size_bytes() {
37837        assert_eq!(format_dir_size(500), "500 B");
37838    }
37839
37840    #[test]
37841    fn format_dir_size_kilobytes() {
37842        assert_eq!(format_dir_size(2048), "2 KB");
37843    }
37844
37845    #[test]
37846    fn format_dir_size_megabytes() {
37847        assert!(format_dir_size(5 * 1_048_576).contains("MB"));
37848    }
37849
37850    #[test]
37851    fn format_dir_size_gigabytes() {
37852        assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
37853    }
37854
37855    #[test]
37856    fn format_dir_size_zero() {
37857        assert_eq!(format_dir_size(0), "0 B");
37858    }
37859
37860    // ── civil_from_days ───────────────────────────────────────────────────────
37861
37862    #[test]
37863    fn civil_from_days_epoch() {
37864        assert_eq!(civil_from_days(0), (1970, 1, 1));
37865    }
37866
37867    #[test]
37868    fn civil_from_days_one_year_later() {
37869        assert_eq!(civil_from_days(365), (1971, 1, 1));
37870    }
37871
37872    #[test]
37873    fn civil_from_days_31_days_is_feb_1_1970() {
37874        assert_eq!(civil_from_days(31), (1970, 2, 1));
37875    }
37876
37877    // ── format_system_time ────────────────────────────────────────────────────
37878
37879    #[test]
37880    fn format_system_time_unix_epoch_formats_correctly() {
37881        assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
37882    }
37883
37884    #[test]
37885    fn format_system_time_31_days_after_epoch() {
37886        let t = UNIX_EPOCH + Duration::from_hours(744);
37887        assert_eq!(format_system_time(t), "1970-02-01 00:00");
37888    }
37889
37890    #[test]
37891    fn format_system_time_before_epoch_returns_dash() {
37892        if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
37893            assert_eq!(format_system_time(before), "-");
37894        }
37895    }
37896
37897    // ── detect_language_name ──────────────────────────────────────────────────
37898
37899    #[test]
37900    fn detect_language_name_dot_c() {
37901        assert_eq!(detect_language_name("main.c"), Some("C"));
37902    }
37903
37904    #[test]
37905    fn detect_language_name_dot_h() {
37906        assert_eq!(detect_language_name("defs.h"), Some("C"));
37907    }
37908
37909    #[test]
37910    fn detect_language_name_dot_cpp() {
37911        assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
37912    }
37913
37914    #[test]
37915    fn detect_language_name_dot_py() {
37916        assert_eq!(detect_language_name("script.py"), Some("Python"));
37917    }
37918
37919    #[test]
37920    fn detect_language_name_dot_ps1() {
37921        assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
37922    }
37923
37924    #[test]
37925    fn detect_language_name_dot_cs() {
37926        assert_eq!(detect_language_name("Program.cs"), Some("C#"));
37927    }
37928
37929    #[test]
37930    fn detect_language_name_dot_sh() {
37931        assert_eq!(detect_language_name("run.sh"), Some("Shell"));
37932    }
37933
37934    #[test]
37935    fn detect_language_name_unknown_txt() {
37936        assert_eq!(detect_language_name("notes.txt"), None);
37937    }
37938
37939    // ── language_icon_file ────────────────────────────────────────────────────
37940
37941    #[test]
37942    fn language_icon_file_c() {
37943        assert_eq!(language_icon_file("C"), Some("c.png"));
37944    }
37945
37946    #[test]
37947    fn language_icon_file_python() {
37948        assert_eq!(language_icon_file("Python"), Some("python.png"));
37949    }
37950
37951    #[test]
37952    fn language_icon_file_dockerfile() {
37953        assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
37954    }
37955
37956    #[test]
37957    fn language_icon_file_rust_is_none() {
37958        assert!(language_icon_file("Rust").is_none());
37959    }
37960
37961    #[test]
37962    fn language_icon_file_unknown_is_none() {
37963        assert!(language_icon_file("Fortran").is_none());
37964    }
37965
37966    // ── language_inline_svg ───────────────────────────────────────────────────
37967
37968    #[test]
37969    fn language_inline_svg_rust_is_svg() {
37970        let svg = language_inline_svg("Rust").unwrap();
37971        assert!(svg.starts_with("<svg"));
37972    }
37973
37974    #[test]
37975    fn language_inline_svg_typescript_is_some() {
37976        assert!(language_inline_svg("TypeScript").is_some());
37977    }
37978
37979    #[test]
37980    fn language_inline_svg_unknown_is_none() {
37981        assert!(language_inline_svg("Fortran").is_none());
37982    }
37983
37984    // ── classify_preview_file ─────────────────────────────────────────────────
37985
37986    #[test]
37987    fn classify_preview_file_c_supported() {
37988        assert!(matches!(
37989            classify_preview_file("main.c"),
37990            PreviewKind::Supported
37991        ));
37992    }
37993
37994    #[test]
37995    fn classify_preview_file_python_supported() {
37996        assert!(matches!(
37997            classify_preview_file("script.py"),
37998            PreviewKind::Supported
37999        ));
38000    }
38001
38002    #[test]
38003    fn classify_preview_file_png_skipped() {
38004        assert!(matches!(
38005            classify_preview_file("image.png"),
38006            PreviewKind::Skipped
38007        ));
38008    }
38009
38010    #[test]
38011    fn classify_preview_file_zip_skipped() {
38012        assert!(matches!(
38013            classify_preview_file("archive.zip"),
38014            PreviewKind::Skipped
38015        ));
38016    }
38017
38018    #[test]
38019    fn classify_preview_file_min_js_skipped() {
38020        assert!(matches!(
38021            classify_preview_file("bundle.min.js"),
38022            PreviewKind::Skipped
38023        ));
38024    }
38025
38026    #[test]
38027    fn classify_preview_file_rs_unsupported() {
38028        assert!(matches!(
38029            classify_preview_file("main.rs"),
38030            PreviewKind::Unsupported
38031        ));
38032    }
38033
38034    // ── preview_relative_path ─────────────────────────────────────────────────
38035
38036    #[test]
38037    fn preview_relative_path_strips_root() {
38038        let root = PathBuf::from("/project");
38039        let path = PathBuf::from("/project/src/main.c");
38040        assert_eq!(preview_relative_path(&root, &path), "src/main.c");
38041    }
38042
38043    #[test]
38044    fn preview_relative_path_unrooted_includes_filename() {
38045        let root = PathBuf::from("/other");
38046        let path = PathBuf::from("/project/src/main.c");
38047        let result = preview_relative_path(&root, &path);
38048        assert!(result.contains("main.c"));
38049    }
38050
38051    #[test]
38052    fn preview_relative_path_uses_forward_slashes() {
38053        let root = PathBuf::from("/project");
38054        let path = PathBuf::from("/project/a/b/c.py");
38055        assert!(!preview_relative_path(&root, &path).contains('\\'));
38056    }
38057
38058    // ── wildcard_match ────────────────────────────────────────────────────────
38059
38060    #[test]
38061    fn wildcard_match_exact_equal() {
38062        assert!(wildcard_match("foo", "foo"));
38063    }
38064
38065    #[test]
38066    fn wildcard_match_exact_mismatch() {
38067        assert!(!wildcard_match("foo", "bar"));
38068    }
38069
38070    #[test]
38071    fn wildcard_match_star_suffix() {
38072        assert!(wildcard_match("*.rs", "main.rs"));
38073    }
38074
38075    #[test]
38076    fn wildcard_match_star_middle_requires_suffix() {
38077        assert!(!wildcard_match("a*b", "ac"));
38078    }
38079
38080    #[test]
38081    fn wildcard_match_question_mark_single_char() {
38082        assert!(wildcard_match("f?o", "foo"));
38083    }
38084
38085    #[test]
38086    fn wildcard_match_double_star_nested() {
38087        assert!(wildcard_match("src/**", "src/a/b/c.rs"));
38088    }
38089
38090    #[test]
38091    fn wildcard_match_star_directory_entry() {
38092        assert!(wildcard_match("vendor/*", "vendor/crate"));
38093    }
38094
38095    #[test]
38096    fn wildcard_match_no_cross_prefix() {
38097        assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
38098    }
38099
38100    // ── should_skip_preview_directory ────────────────────────────────────────
38101
38102    #[test]
38103    fn should_skip_empty_relative_is_false() {
38104        assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
38105    }
38106
38107    #[test]
38108    fn should_skip_matching_pattern() {
38109        assert!(should_skip_preview_directory(
38110            "vendor",
38111            &["vendor".to_string()]
38112        ));
38113    }
38114
38115    #[test]
38116    fn should_skip_non_matching() {
38117        assert!(!should_skip_preview_directory(
38118            "src",
38119            &["vendor".to_string()]
38120        ));
38121    }
38122
38123    #[test]
38124    fn should_skip_wildcard_prefix() {
38125        assert!(should_skip_preview_directory(
38126            "target/debug",
38127            &["target*".to_string()]
38128        ));
38129    }
38130
38131    // ── should_include_preview_file ───────────────────────────────────────────
38132
38133    #[test]
38134    fn should_include_empty_relative_always_true() {
38135        assert!(should_include_preview_file("", &[], &[]));
38136    }
38137
38138    #[test]
38139    fn should_include_no_patterns_includes_all() {
38140        assert!(should_include_preview_file("src/main.c", &[], &[]));
38141    }
38142
38143    #[test]
38144    fn should_include_excluded_by_pattern() {
38145        assert!(!should_include_preview_file(
38146            "vendor/lib.c",
38147            &[],
38148            &["vendor/*".to_string()]
38149        ));
38150    }
38151
38152    #[test]
38153    fn should_include_include_pattern_filters() {
38154        assert!(!should_include_preview_file(
38155            "tests/test_foo.c",
38156            &["src/*".to_string()],
38157            &[]
38158        ));
38159    }
38160
38161    // ── escape_html ───────────────────────────────────────────────────────────
38162
38163    #[test]
38164    fn escape_html_ampersand() {
38165        assert_eq!(escape_html("a&b"), "a&amp;b");
38166    }
38167
38168    #[test]
38169    fn escape_html_angle_brackets() {
38170        assert_eq!(escape_html("<br>"), "&lt;br&gt;");
38171    }
38172
38173    #[test]
38174    fn escape_html_double_quote() {
38175        assert_eq!(escape_html(r#"say "hello""#), "say &quot;hello&quot;");
38176    }
38177
38178    #[test]
38179    fn escape_html_single_quote() {
38180        assert_eq!(escape_html("it's"), "it&#39;s");
38181    }
38182
38183    #[test]
38184    fn escape_html_plain_text_unchanged() {
38185        assert_eq!(escape_html("hello world"), "hello world");
38186    }
38187
38188    // ── sum_added / removed / unmodified code lines ───────────────────────────
38189
38190    fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
38191        sloc_core::ScanComparison {
38192            summary: sloc_core::SummaryDelta {
38193                baseline_run_id: "base".to_string(),
38194                current_run_id: "curr".to_string(),
38195                baseline_timestamp: chrono::Utc::now(),
38196                current_timestamp: chrono::Utc::now(),
38197                baseline_files: 4,
38198                current_files: 4,
38199                files_analyzed_delta: 0,
38200                baseline_code: 330,
38201                current_code: 400,
38202                code_lines_delta: 70,
38203                baseline_comments: 0,
38204                current_comments: 0,
38205                comment_lines_delta: 0,
38206                blank_lines_delta: 0,
38207                total_lines_delta: 70,
38208                coverage_lines_hit_delta: None,
38209                coverage_line_pct_delta: None,
38210                baseline_coverage_line_pct: None,
38211                current_coverage_line_pct: None,
38212            },
38213            file_deltas: vec![
38214                sloc_core::FileDelta {
38215                    relative_path: "added.rs".to_string(),
38216                    language: Some("Rust".to_string()),
38217                    status: FileChangeStatus::Added,
38218                    baseline_code: 0,
38219                    current_code: 100,
38220                    code_delta: 100,
38221                    baseline_comment: 0,
38222                    current_comment: 0,
38223                    comment_delta: 0,
38224                    baseline_blank: 0,
38225                    current_blank: 0,
38226                    blank_delta: 0,
38227                    total_delta: 100,
38228                },
38229                sloc_core::FileDelta {
38230                    relative_path: "removed.rs".to_string(),
38231                    language: Some("Rust".to_string()),
38232                    status: FileChangeStatus::Removed,
38233                    baseline_code: 50,
38234                    current_code: 0,
38235                    code_delta: -50,
38236                    baseline_comment: 0,
38237                    current_comment: 0,
38238                    comment_delta: 0,
38239                    baseline_blank: 0,
38240                    current_blank: 0,
38241                    blank_delta: 0,
38242                    total_delta: -50,
38243                },
38244                sloc_core::FileDelta {
38245                    relative_path: "modified.rs".to_string(),
38246                    language: Some("Rust".to_string()),
38247                    status: FileChangeStatus::Modified,
38248                    baseline_code: 80,
38249                    current_code: 100,
38250                    code_delta: 20,
38251                    baseline_comment: 0,
38252                    current_comment: 0,
38253                    comment_delta: 0,
38254                    baseline_blank: 0,
38255                    current_blank: 0,
38256                    blank_delta: 0,
38257                    total_delta: 20,
38258                },
38259                sloc_core::FileDelta {
38260                    relative_path: "unchanged.rs".to_string(),
38261                    language: Some("Rust".to_string()),
38262                    status: FileChangeStatus::Unchanged,
38263                    baseline_code: 200,
38264                    current_code: 200,
38265                    code_delta: 0,
38266                    baseline_comment: 0,
38267                    current_comment: 0,
38268                    comment_delta: 0,
38269                    baseline_blank: 0,
38270                    current_blank: 0,
38271                    blank_delta: 0,
38272                    total_delta: 0,
38273                },
38274            ],
38275            files_added: 1,
38276            files_removed: 1,
38277            files_modified: 1,
38278            files_unchanged: 1,
38279            files_total: 4,
38280        }
38281    }
38282
38283    #[test]
38284    fn sum_added_counts_added_and_positive_modified() {
38285        let cmp = make_mixed_scan_comparison();
38286        assert_eq!(sum_added_code_lines(&cmp), 120);
38287    }
38288
38289    #[test]
38290    fn sum_removed_counts_removed_baseline() {
38291        let cmp = make_mixed_scan_comparison();
38292        assert_eq!(sum_removed_code_lines(&cmp), 50);
38293    }
38294
38295    #[test]
38296    fn sum_unmodified_counts_unchanged_files() {
38297        let cmp = make_mixed_scan_comparison();
38298        assert_eq!(sum_unmodified_code_lines(&cmp), 200);
38299    }
38300
38301    // ── detect_coverage_tool ──────────────────────────────────────────────────
38302
38303    #[test]
38304    fn detect_coverage_tool_rust_project() {
38305        let dir = tempfile::tempdir().unwrap();
38306        std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
38307        let (tool, cmd) = detect_coverage_tool(dir.path());
38308        assert_eq!(tool, Some("cargo-llvm-cov"));
38309        assert!(cmd.is_some());
38310    }
38311
38312    #[test]
38313    fn detect_coverage_tool_java_gradle() {
38314        let dir = tempfile::tempdir().unwrap();
38315        std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
38316        let (tool, _) = detect_coverage_tool(dir.path());
38317        assert_eq!(tool, Some("jacoco"));
38318    }
38319
38320    #[test]
38321    fn detect_coverage_tool_python_pyproject() {
38322        let dir = tempfile::tempdir().unwrap();
38323        std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
38324        let (tool, _) = detect_coverage_tool(dir.path());
38325        assert_eq!(tool, Some("pytest-cov"));
38326    }
38327
38328    #[test]
38329    fn detect_coverage_tool_unknown_project() {
38330        let dir = tempfile::tempdir().unwrap();
38331        let (tool, cmd) = detect_coverage_tool(dir.path());
38332        assert!(tool.is_none() && cmd.is_none());
38333    }
38334
38335    // ── sanitize_path_str / display_path ─────────────────────────────────────
38336
38337    #[test]
38338    fn sanitize_path_str_unc_drive_stripped() {
38339        assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
38340    }
38341
38342    #[test]
38343    fn sanitize_path_str_unc_network_stripped() {
38344        assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
38345    }
38346
38347    #[test]
38348    fn sanitize_path_str_plain_path_unchanged() {
38349        assert_eq!(
38350            sanitize_path_str("/home/user/project"),
38351            "/home/user/project"
38352        );
38353    }
38354
38355    #[test]
38356    fn display_path_plain_linux_unchanged() {
38357        assert_eq!(
38358            display_path(Path::new("/home/user/project")),
38359            "/home/user/project"
38360        );
38361    }
38362
38363    #[test]
38364    fn display_path_unc_drive_stripped() {
38365        let result = display_path(Path::new(r"\\?\C:\Users\user"));
38366        assert_eq!(result, r"C:\Users\user");
38367    }
38368
38369    #[test]
38370    fn display_path_unc_network_stripped() {
38371        let result = display_path(Path::new(r"\\?\UNC\server\share"));
38372        assert_eq!(result, r"\\server\share");
38373    }
38374}
38375
38376#[cfg(test)]
38377mod coverage_boost_unit_tests {
38378    use super::*;
38379    use std::path::{Path, PathBuf};
38380
38381    // Both scenarios live in one test (sequential, under a Tokio runtime) because
38382    // load_runtime_security_config spawns a pruning task and mutates process-global
38383    // env vars — parallel sub-tests would race on both.
38384    #[tokio::test]
38385    async fn runtime_security_config_scenarios() {
38386        // FIXME: Audit that the environment access only happens in single-threaded code.
38387        unsafe { std::env::remove_var("SLOC_API_KEYS") };
38388        // FIXME: Audit that the environment access only happens in single-threaded code.
38389        unsafe { std::env::remove_var("SLOC_API_KEY") };
38390        // FIXME: Audit that the environment access only happens in single-threaded code.
38391        unsafe { std::env::remove_var("SLOC_TLS_CERT") };
38392        // FIXME: Audit that the environment access only happens in single-threaded code.
38393        unsafe { std::env::remove_var("SLOC_TLS_KEY") };
38394        // FIXME: Audit that the environment access only happens in single-threaded code.
38395        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
38396        // FIXME: Audit that the environment access only happens in single-threaded code.
38397        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
38398        let cfg = load_runtime_security_config(false);
38399        assert!(cfg.api_keys.is_empty());
38400        assert!(!cfg.tls_enabled);
38401        assert!(!cfg.trust_proxy);
38402
38403        // FIXME: Audit that the environment access only happens in single-threaded code.
38404        unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
38405        // FIXME: Audit that the environment access only happens in single-threaded code.
38406        unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
38407        // FIXME: Audit that the environment access only happens in single-threaded code.
38408        unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
38409        // FIXME: Audit that the environment access only happens in single-threaded code.
38410        unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
38411        // FIXME: Audit that the environment access only happens in single-threaded code.
38412        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
38413        // FIXME: Audit that the environment access only happens in single-threaded code.
38414        unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
38415        let cfg = load_runtime_security_config(true);
38416        assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
38417        assert!(cfg.trust_proxy);
38418        assert_eq!(cfg.trusted_proxy_ips.len(), 2);
38419        // FIXME: Audit that the environment access only happens in single-threaded code.
38420        unsafe { std::env::remove_var("SLOC_API_KEYS") };
38421        // FIXME: Audit that the environment access only happens in single-threaded code.
38422        unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
38423        // FIXME: Audit that the environment access only happens in single-threaded code.
38424        unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
38425        // FIXME: Audit that the environment access only happens in single-threaded code.
38426        unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
38427        // FIXME: Audit that the environment access only happens in single-threaded code.
38428        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
38429        // FIXME: Audit that the environment access only happens in single-threaded code.
38430        unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
38431    }
38432
38433    #[test]
38434    fn cors_layer_builds_both_modes() {
38435        let _ = build_cors_layer(true);
38436        let _ = build_cors_layer(false);
38437    }
38438
38439    #[test]
38440    fn primary_lan_ip_callable() {
38441        // May be Some or None depending on the host; both are valid.
38442        let _ = primary_lan_ip();
38443    }
38444
38445    #[test]
38446    fn safe_redirect_allows_relative_rejects_absolute() {
38447        assert_eq!(safe_redirect("/view-reports"), "/view-reports");
38448        assert_eq!(safe_redirect("https://evil.example/x"), "/");
38449        assert_eq!(safe_redirect("javascript:alert(1)"), "/");
38450        assert_eq!(default_redirect(), "/view-reports");
38451    }
38452
38453    #[test]
38454    fn tarball_size_caps_env_override() {
38455        // FIXME: Audit that the environment access only happens in single-threaded code.
38456        unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
38457        // FIXME: Audit that the environment access only happens in single-threaded code.
38458        unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
38459        let (c, d) = parse_tarball_size_caps();
38460        assert_eq!(c, 1024 * 1024);
38461        assert_eq!(d, 2 * 1024 * 1024);
38462        // FIXME: Audit that the environment access only happens in single-threaded code.
38463        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
38464        // FIXME: Audit that the environment access only happens in single-threaded code.
38465        unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
38466        let (c2, _) = parse_tarball_size_caps();
38467        assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
38468    }
38469
38470    #[test]
38471    fn upload_path_helpers() {
38472        let base = upload_base_dir();
38473        let staged = upload_staging_path("abc123");
38474        assert!(staged.starts_with(&base));
38475        assert!(
38476            is_upload_tmp_path(&staged),
38477            "staging path is an upload tmp path"
38478        );
38479        assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
38480    }
38481
38482    #[test]
38483    fn git_clones_dir_env_override() {
38484        // FIXME: Audit that the environment access only happens in single-threaded code.
38485        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
38486        let def = resolve_git_clones_dir(Path::new("/out"));
38487        assert_eq!(def, PathBuf::from("/out").join("git-clones"));
38488        // FIXME: Audit that the environment access only happens in single-threaded code.
38489        unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
38490        assert_eq!(
38491            resolve_git_clones_dir(Path::new("/out")),
38492            PathBuf::from("/custom/clones")
38493        );
38494        // FIXME: Audit that the environment access only happens in single-threaded code.
38495        unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
38496    }
38497
38498    #[test]
38499    fn html_report_file_detection() {
38500        let dir = std::env::temp_dir().join("sloc_html_detect");
38501        let _ = std::fs::create_dir_all(&dir);
38502        let good = dir.join("report_x.html");
38503        std::fs::write(&good, "<html></html>").unwrap();
38504        let bad = dir.join("notes.txt");
38505        std::fs::write(&bad, "x").unwrap();
38506        assert!(is_html_report_file(&good));
38507        assert!(!is_html_report_file(&bad));
38508        assert!(find_html_report_in_dir(&dir).is_some());
38509        let _ = std::fs::remove_dir_all(&dir);
38510    }
38511
38512    #[test]
38513    fn multi_delta_class_and_format() {
38514        assert_eq!(multi_delta_class(5), "pos");
38515        assert_eq!(multi_delta_class(-5), "neg");
38516        assert_eq!(multi_delta_class(0), "zero");
38517        assert_eq!(multi_fmt_delta(3), "+3");
38518        assert_eq!(multi_fmt_delta(-3), "-3");
38519        assert_eq!(multi_fmt_delta(0), "0");
38520    }
38521
38522    #[test]
38523    fn git_clone_dest_sanitizes() {
38524        let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
38525        assert!(dest.starts_with("/clones"));
38526        let name = dest.file_name().unwrap().to_str().unwrap();
38527        assert!(
38528            name.chars()
38529                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
38530        );
38531    }
38532}
38533
38534#[cfg(test)]
38535mod tests_private {
38536    use super::*;
38537    use std::io::Read;
38538
38539    /// A fresh per-call CSP nonce, generated the same way production does. Tests must not
38540    /// embed a literal nonce string — a hard-coded value flowing into a `nonce` parameter
38541    /// trips CodeQL's `rust/hard-coded-cryptographic-value` query.
38542    fn test_nonce() -> String {
38543        uuid::Uuid::new_v4().to_string().replace('-', "")
38544    }
38545
38546    // ── Server-mode fail-closed auth gate ──────────────────────────────────────
38547
38548    #[test]
38549    fn local_mode_never_refuses_start() {
38550        // Desktop / local mode is open by design regardless of key presence.
38551        assert!(!refuse_unauthenticated_server(false, false));
38552        assert!(!refuse_unauthenticated_server(false, true));
38553    }
38554
38555    // ── Network-facing bind promotes to server mode ────────────────────────────
38556
38557    #[test]
38558    fn loopback_binds_are_not_network_facing() {
38559        assert!(!bind_is_network_facing("127.0.0.1:4317"));
38560        assert!(!bind_is_network_facing("127.0.0.1"));
38561        assert!(!bind_is_network_facing("[::1]:4317"));
38562        assert!(!bind_is_network_facing("localhost:4317"));
38563        assert!(!bind_is_network_facing("LOCALHOST"));
38564        assert!(!bind_is_network_facing("127.5.5.5:8080")); // whole 127/8 is loopback
38565    }
38566
38567    #[test]
38568    fn wildcard_and_lan_binds_are_network_facing() {
38569        assert!(bind_is_network_facing("0.0.0.0:4317"));
38570        assert!(bind_is_network_facing("[::]:4317"));
38571        assert!(bind_is_network_facing("192.168.1.50:4317"));
38572        assert!(bind_is_network_facing("10.0.0.1:80"));
38573        // A hostname or an unparseable value fails safe to network-facing.
38574        assert!(bind_is_network_facing("sloc.corp.local:4317"));
38575        assert!(bind_is_network_facing("not a bind address"));
38576    }
38577
38578    // ── Disk-cap selection + combining ─────────────────────────────────────────
38579
38580    #[test]
38581    fn combine_disk_caps_takes_the_smaller_and_ignores_zero() {
38582        assert_eq!(combine_disk_caps_mb(Some(100), Some(200)), Some(100));
38583        assert_eq!(combine_disk_caps_mb(Some(200), Some(100)), Some(100));
38584        assert_eq!(combine_disk_caps_mb(Some(100), None), Some(100));
38585        assert_eq!(combine_disk_caps_mb(None, Some(100)), Some(100));
38586        assert_eq!(combine_disk_caps_mb(None, None), None);
38587        // Zero means "unset", not "cap at zero".
38588        assert_eq!(combine_disk_caps_mb(Some(0), Some(50)), Some(50));
38589        assert_eq!(combine_disk_caps_mb(Some(0), Some(0)), None);
38590    }
38591
38592    #[test]
38593    fn size_cap_keeps_newest_runs_that_fit() {
38594        // Newest-first: a=30, b=30, c=30; cap 70 keeps a+b (60), deletes c.
38595        let sized = vec![
38596            ("a".to_string(), 30),
38597            ("b".to_string(), 30),
38598            ("c".to_string(), 30),
38599        ];
38600        let del = select_runs_over_size_cap(&sized, 70);
38601        assert_eq!(del.len(), 1);
38602        assert!(del.contains("c"));
38603        assert!(!del.contains("a"));
38604    }
38605
38606    #[test]
38607    fn size_cap_under_budget_deletes_nothing() {
38608        let sized = vec![("a".to_string(), 10), ("b".to_string(), 10)];
38609        assert!(select_runs_over_size_cap(&sized, 1_000).is_empty());
38610    }
38611
38612    #[test]
38613    fn size_cap_zero_deletes_everything() {
38614        let sized = vec![("a".to_string(), 1), ("b".to_string(), 1)];
38615        assert_eq!(select_runs_over_size_cap(&sized, 0).len(), 2);
38616    }
38617
38618    // ── Host allowlist / public URL helpers ────────────────────────────────────
38619
38620    #[test]
38621    fn host_of_url_extracts_authority() {
38622        assert_eq!(
38623            host_of_url("https://sloc.corp.local"),
38624            Some("sloc.corp.local".into())
38625        );
38626        assert_eq!(
38627            host_of_url("http://sloc.corp.local:4317/path?q=1"),
38628            Some("sloc.corp.local:4317".into())
38629        );
38630        assert_eq!(
38631            host_of_url("user@host.example:8080"),
38632            Some("host.example:8080".into())
38633        );
38634        assert_eq!(host_of_url("HOST.Example"), Some("host.example".into()));
38635        assert_eq!(host_of_url(""), None);
38636    }
38637
38638    #[test]
38639    fn parse_allowed_hosts_splits_commas_and_whitespace() {
38640        let got = parse_allowed_hosts(" sloc.corp.local, 10.0.0.5:4317\n host2 ");
38641        assert_eq!(got, vec!["sloc.corp.local", "10.0.0.5:4317", "host2"]);
38642        assert!(parse_allowed_hosts("   ").is_empty());
38643    }
38644
38645    #[test]
38646    fn host_is_allowed_matches_with_and_without_port() {
38647        let allowed = parse_allowed_hosts("sloc.corp.local, 10.0.0.5:4317");
38648        // Entry without a port matches any request port.
38649        assert!(host_is_allowed("sloc.corp.local", &allowed));
38650        assert!(host_is_allowed("sloc.corp.local:4317", &allowed));
38651        assert!(host_is_allowed("SLOC.CORP.LOCAL:9999", &allowed));
38652        // Entry with a port must match exactly.
38653        assert!(host_is_allowed("10.0.0.5:4317", &allowed));
38654        assert!(!host_is_allowed("10.0.0.5:9999", &allowed));
38655        // Not on the list.
38656        assert!(!host_is_allowed("evil.example", &allowed));
38657        assert!(!host_is_allowed("", &allowed));
38658    }
38659
38660    #[test]
38661    fn host_check_exempts_probes_and_webhooks() {
38662        assert!(host_check_exempt("/healthz"));
38663        assert!(host_check_exempt("/readyz"));
38664        assert!(host_check_exempt("/metrics"));
38665        assert!(host_check_exempt("/webhooks/github"));
38666        assert!(!host_check_exempt("/"));
38667        assert!(!host_check_exempt("/analyze"));
38668    }
38669
38670    #[test]
38671    fn split_host_port_handles_ipv6_and_names() {
38672        assert_eq!(split_host_port("host"), ("host", None));
38673        assert_eq!(split_host_port("host:80"), ("host", Some("80")));
38674        assert_eq!(split_host_port("[::1]:80"), ("::1", Some("80")));
38675        assert_eq!(split_host_port("[2001:db8::1]"), ("2001:db8::1", None));
38676    }
38677
38678    // ── Code Ownership page ────────────────────────────────────────────────────
38679
38680    #[test]
38681    fn code_ownership_empty_state_renders_guidance() {
38682        let html = render_code_ownership_html(&test_nonce(), None, "myrepo", &[], &[], None, "");
38683        assert!(html.contains("No ownership data"));
38684        assert!(html.contains("oxide-sloc analyze"));
38685        assert!(html.contains("myrepo"));
38686        assert!(html.contains("site-footer"));
38687        assert!(html.contains("id=\"theme-toggle\""));
38688        assert!(html.contains("Code Ownership"));
38689    }
38690
38691    #[test]
38692    fn code_ownership_project_selector_renders_and_marks_selection() {
38693        // No projects: the picker is omitted entirely.
38694        assert_eq!(build_project_selector(&[], None), "");
38695
38696        // Projects present, "all projects" selected (None): the All option is selected and every
38697        // project is listed as an option.
38698        let projects = vec!["alpha".to_string(), "beta".to_string()];
38699        let all = build_project_selector(&projects, None);
38700        assert!(all.contains("own-project-select"));
38701        assert!(all.contains(r#"<option value="" selected>All projects (latest scan)</option>"#));
38702        assert!(all.contains(r#"<option value="alpha">alpha</option>"#));
38703        assert!(all.contains(r#"<option value="beta">beta</option>"#));
38704
38705        // A specific project selected: that option carries the selected attribute, not "All".
38706        let beta = build_project_selector(&projects, Some("beta"));
38707        assert!(beta.contains(r#"<option value="beta" selected>beta</option>"#));
38708        assert!(beta.contains(r#"<option value="">All projects (latest scan)</option>"#));
38709
38710        // The selector is wired into the full page when projects exist.
38711        let page = render_code_ownership_html(
38712            &test_nonce(),
38713            None,
38714            "myrepo",
38715            &[],
38716            &projects,
38717            Some("alpha"),
38718            "",
38719        );
38720        assert!(page.contains("own-project-select"));
38721        assert!(page.contains(r#"<option value="alpha" selected>alpha</option>"#));
38722    }
38723
38724    #[test]
38725    fn parse_remote_host_slug_handles_https_ssh_and_scp() {
38726        assert_eq!(
38727            parse_remote_host_slug("https://github.com/oxide-sloc/oxide-sloc.git"),
38728            Some(("github.com".into(), "oxide-sloc/oxide-sloc".into()))
38729        );
38730        assert_eq!(
38731            parse_remote_host_slug("git@github.com:owner/repo.git"),
38732            Some(("github.com".into(), "owner/repo".into()))
38733        );
38734        assert_eq!(
38735            parse_remote_host_slug("ssh://git@gitlab.com/group/sub/repo"),
38736            Some(("gitlab.com".into(), "group/sub/repo".into()))
38737        );
38738        // Credentials in the authority are stripped.
38739        assert_eq!(
38740            parse_remote_host_slug("https://user:tok@bitbucket.org/team/repo"),
38741            Some(("bitbucket.org".into(), "team/repo".into()))
38742        );
38743        assert_eq!(parse_remote_host_slug("not-a-url"), None);
38744        assert_eq!(parse_remote_host_slug("https://github.com/"), None);
38745    }
38746
38747    #[test]
38748    fn author_profile_url_prefers_github_noreply_then_falls_back_to_host_filter() {
38749        // GitHub noreply email carries the exact login -> direct profile, regardless of remote.
38750        assert_eq!(
38751            author_profile_url(None, "Nima", "12345+nimzshafie@users.noreply.github.com"),
38752            Some("https://github.com/nimzshafie".into())
38753        );
38754        assert_eq!(
38755            author_profile_url(None, "Nima", "nimzshafie@users.noreply.github.com"),
38756            Some("https://github.com/nimzshafie".into())
38757        );
38758        // Plain email on a GitHub remote -> commit history filtered by author email.
38759        assert_eq!(
38760            author_profile_url(
38761                Some("https://github.com/owner/repo.git"),
38762                "Nima Shafie",
38763                "nima@corp.com"
38764            ),
38765            Some("https://github.com/owner/repo/commits?author=nima%40corp.com".into())
38766        );
38767        // GitLab filters by name; Bitbucket by email.
38768        assert_eq!(
38769            author_profile_url(Some("git@gitlab.com:grp/repo.git"), "A B", "a@b.com"),
38770            Some("https://gitlab.com/grp/repo/-/commits?author=A%20B".into())
38771        );
38772        assert_eq!(
38773            author_profile_url(Some("https://bitbucket.org/t/r"), "A B", "a@b.com"),
38774            Some("https://bitbucket.org/t/r/commits/?author=a%40b.com".into())
38775        );
38776        // Unknown host / no remote -> no link (never link to an arbitrary domain).
38777        assert_eq!(
38778            author_profile_url(Some("https://evil.example/x/y"), "A", "a@b.com"),
38779            None
38780        );
38781        assert_eq!(author_profile_url(None, "A", "a@b.com"), None);
38782    }
38783
38784    #[test]
38785    fn code_ownership_populated_renders_authors_and_bus_factor() {
38786        let json = serde_json::json!({
38787            "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
38788            "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
38789            "effective_configuration": {},
38790            "input_roots": ["/tmp/myrepo"],
38791            "summary_totals": {"files_considered":1,"files_analyzed":1,"files_skipped":0,"total_physical_lines":260,"code_lines":200,"comment_lines":40,"blank_lines":20,"mixed_lines_separate":0},
38792            "totals_by_language": [],
38793            "per_file_records": [],
38794            "skipped_file_records": [],
38795            "warnings": [],
38796            "authors": [
38797                {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nimzshafie@gmail.com",
38798                 "aliases":[{"name":"Nima Shafie","email":"nimzshafie@gmail.com"},{"name":"nshafie","email":"nimzshafie@gmail.com"}],
38799                 "counts":{"code_lines":150,"comment_lines":30,"blank_lines":15,"total_lines":195}},
38800                {"id":1,"canonical_name":"Other Dev","canonical_email":"other@example.com",
38801                 "aliases":[{"name":"Other Dev","email":"other@example.com"}],
38802                 "counts":{"code_lines":50,"comment_lines":10,"blank_lines":5,"total_lines":65}}
38803            ]
38804        });
38805        let run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
38806        let html =
38807            render_code_ownership_html(&test_nonce(), Some(&run), "myrepo", &[], &[], None, "");
38808        assert!(html.contains("Nima Shafie"));
38809        assert!(html.contains("Other Dev"));
38810        assert!(html.contains("own-data"));
38811        assert!(html.contains("Contributors"));
38812        assert!(html.contains("own-bar-fill"));
38813        assert!(!html.contains("No ownership data"));
38814        // Nima owns 150 of 200 code lines (75% >= 50%), so the bus factor is 1.
38815        assert!(html.contains(">1</div><div class=\"stat-chip-label\">Bus Factor"));
38816        // Combine-contributors merge panel with a checkbox per author.
38817        assert!(html.contains("Combine contributors"));
38818        assert!(html.contains("/api/ownership/merge"));
38819        assert!(html.contains("name=\"email\" value=\"nimzshafie@gmail.com\""));
38820    }
38821
38822    #[test]
38823    fn code_ownership_shows_active_merge_group() {
38824        let group = AuthorMergeGroup {
38825            canonical_name: "Nima Shafie".into(),
38826            canonical_email: "nima@corp.com".into(),
38827            members: vec!["nima@corp.com".into(), "nima@personal.com".into()],
38828        };
38829        let json = serde_json::json!({
38830            "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
38831            "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
38832            "effective_configuration": {},
38833            "input_roots": ["/tmp/myrepo"],
38834            "summary_totals": {"files_considered":1,"files_analyzed":1,"files_skipped":0,"total_physical_lines":260,"code_lines":200,"comment_lines":40,"blank_lines":20,"mixed_lines_separate":0},
38835            "totals_by_language": [],
38836            "per_file_records": [],
38837            "skipped_file_records": [],
38838            "warnings": [],
38839            "authors": [
38840                {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nima@corp.com","aliases":[],
38841                 "counts":{"code_lines":150,"comment_lines":30,"blank_lines":15,"total_lines":195}}
38842            ]
38843        });
38844        let run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
38845        let html = render_code_ownership_html(
38846            &test_nonce(),
38847            Some(&run),
38848            "myrepo",
38849            std::slice::from_ref(&group),
38850            &[],
38851            None,
38852            "",
38853        );
38854        assert!(html.contains("Active merges"));
38855        assert!(html.contains("/api/ownership/unmerge"));
38856        assert!(html.contains("Unmerge"));
38857    }
38858
38859    #[test]
38860    fn code_ownership_language_table_and_files_owned() {
38861        use sloc_core::{AuthorLineCounts, FileOwnership, FileRecord, FileStatus};
38862        use sloc_languages::Language;
38863
38864        let own = |author_id: u32, code: u64| FileOwnership {
38865            author_id,
38866            counts: AuthorLineCounts {
38867                code_lines: code,
38868                comment_lines: 0,
38869                blank_lines: 0,
38870                total_lines: code,
38871            },
38872        };
38873        let rec = |path: &str, lang: Language, owners: Vec<FileOwnership>| FileRecord {
38874            path: path.into(),
38875            relative_path: path.into(),
38876            language: Some(lang),
38877            size_bytes: 100,
38878            detected_encoding: None,
38879            raw_line_categories: Default::default(),
38880            effective_counts: Default::default(),
38881            status: FileStatus::AnalyzedExact,
38882            warnings: vec![],
38883            generated: false,
38884            minified: false,
38885            vendor: false,
38886            parse_mode: None,
38887            submodule: None,
38888            coverage: None,
38889            style_analysis: None,
38890            cyclomatic_complexity: None,
38891            lsloc: None,
38892            commit_count: None,
38893            last_commit_date: None,
38894            ownership: Some(owners),
38895            content_hash: 0,
38896        };
38897
38898        let json = serde_json::json!({
38899            "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
38900            "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
38901            "effective_configuration": {},
38902            "input_roots": ["/tmp/myrepo"],
38903            "summary_totals": {"files_considered":2,"files_analyzed":2,"files_skipped":0,"total_physical_lines":235,"code_lines":235,"comment_lines":0,"blank_lines":0,"mixed_lines_separate":0},
38904            "totals_by_language": [],
38905            "per_file_records": [],
38906            "skipped_file_records": [],
38907            "warnings": [],
38908            "authors": [
38909                {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nimzshafie@gmail.com","aliases":[],
38910                 "counts":{"code_lines":125,"comment_lines":0,"blank_lines":0,"total_lines":125}},
38911                {"id":1,"canonical_name":"Other Dev","canonical_email":"other@example.com","aliases":[],
38912                 "counts":{"code_lines":110,"comment_lines":0,"blank_lines":0,"total_lines":110}}
38913            ]
38914        });
38915        let mut run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
38916        run.per_file_records = vec![
38917            // Nima (0) owns the Rust file; Other (1) owns the Python file.
38918            rec("a.rs", Language::Rust, vec![own(0, 120), own(1, 30)]),
38919            rec("b.py", Language::Python, vec![own(1, 80), own(0, 5)]),
38920        ];
38921
38922        let html =
38923            render_code_ownership_html(&test_nonce(), Some(&run), "myrepo", &[], &[], None, "");
38924        // The per-language ownership table lists each language with its dominant owner.
38925        assert!(html.contains("Ownership by language"));
38926        assert!(html.contains("Rust"));
38927        assert!(html.contains("Python"));
38928        // Each author is the top owner of exactly one file (Files Owned column populated).
38929        assert!(html.contains("Nima Shafie"));
38930        assert!(html.contains("Other Dev"));
38931    }
38932
38933    #[test]
38934    fn language_badge_maps_known_and_falls_back() {
38935        // Known languages get their abbreviation + a brand-ish color.
38936        assert_eq!(language_badge_meta("Rust"), Some(("Rs", "#DEA584")));
38937        assert_eq!(language_badge_meta("C++"), Some(("C++", "#F34B7D")));
38938        assert_eq!(language_badge_meta("C#"), Some(("C#", "#178600")));
38939        assert_eq!(language_badge_meta("C"), Some(("C", "#555555")));
38940        assert_eq!(language_badge_meta("TypeScript"), Some(("Ts", "#3178C6")));
38941        // Unknown language → no mapping (badge falls back to initials).
38942        assert_eq!(language_badge_meta("Whitespace"), None);
38943
38944        // The rendered badge is inline SVG with the abbreviation and no CSP-blocked inline style.
38945        let svg = language_badge("Rust");
38946        assert!(svg.contains("lang-badge"));
38947        assert!(svg.contains("<svg"));
38948        assert!(svg.contains(">Rs<"));
38949        assert!(svg.contains("#DEA584"));
38950        assert!(!svg.contains("style="));
38951        // Fallback uses uppercased initials for an unmapped language.
38952        assert!(language_badge("Whitespace").contains(">WH<"));
38953    }
38954
38955    #[test]
38956    fn ownership_dev_test_split_classifies_by_file() {
38957        use sloc_core::{AuthorLineCounts, FileOwnership, FileRecord, FileStatus};
38958        use sloc_languages::Language;
38959
38960        let own = |author_id: u32, code: u64| FileOwnership {
38961            author_id,
38962            counts: AuthorLineCounts {
38963                code_lines: code,
38964                comment_lines: 0,
38965                blank_lines: 0,
38966                total_lines: code,
38967            },
38968        };
38969        let rec = |path: &str, owners: Vec<FileOwnership>| FileRecord {
38970            path: path.into(),
38971            relative_path: path.into(),
38972            language: Some(Language::Rust),
38973            size_bytes: 100,
38974            detected_encoding: None,
38975            raw_line_categories: Default::default(),
38976            effective_counts: Default::default(),
38977            status: FileStatus::AnalyzedExact,
38978            warnings: vec![],
38979            generated: false,
38980            minified: false,
38981            vendor: false,
38982            parse_mode: None,
38983            submodule: None,
38984            coverage: None,
38985            style_analysis: None,
38986            cyclomatic_complexity: None,
38987            lsloc: None,
38988            commit_count: None,
38989            last_commit_date: None,
38990            ownership: Some(owners),
38991            content_hash: 0,
38992        };
38993
38994        // Path-convention classification.
38995        assert!(rec("tests/a.rs", vec![]).is_test_file());
38996        assert!(rec("src/foo.spec.ts", vec![]).is_test_file());
38997        assert!(!rec("src/b.rs", vec![]).is_test_file());
38998        // Lexical classification: a detected test symbol flips a non-test path to a test.
38999        let mut lexical = rec("src/c.rs", vec![]);
39000        lexical.raw_line_categories.test_count = 1;
39001        assert!(lexical.is_test_file());
39002
39003        let json = serde_json::json!({
39004            "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
39005            "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
39006            "effective_configuration": {},
39007            "input_roots": ["/tmp/myrepo"],
39008            "summary_totals": {"files_considered":2,"files_analyzed":2,"files_skipped":0,"total_physical_lines":130,"code_lines":130,"comment_lines":0,"blank_lines":0,"mixed_lines_separate":0},
39009            "totals_by_language": [],
39010            "per_file_records": [],
39011            "skipped_file_records": [],
39012            "warnings": [],
39013            "authors": [
39014                {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nima@corp.com","aliases":[],
39015                 "counts":{"code_lines":130,"comment_lines":0,"blank_lines":0,"total_lines":130}}
39016            ]
39017        });
39018        let mut run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
39019        run.per_file_records = vec![
39020            rec("src/b.rs", vec![own(0, 100)]),
39021            rec("tests/a.rs", vec![own(0, 30)]),
39022        ];
39023        let rows = build_ownership_rows(Some(&run), 130);
39024        assert_eq!(rows.len(), 1);
39025        assert_eq!(rows[0].code, 130);
39026        // 30 code lines live in the test file; 100 are development.
39027        assert_eq!(rows[0].test_code, 30);
39028        assert_eq!(rows[0].code.saturating_sub(rows[0].test_code), 100);
39029
39030        // The data island exposes the test split for the client filter.
39031        let dj = ownership_data_json(&rows);
39032        assert!(dj.contains("\"test_code\":30"));
39033    }
39034
39035    #[test]
39036    fn server_mode_with_key_is_allowed() {
39037        assert!(!refuse_unauthenticated_server(true, true));
39038    }
39039
39040    // Env-mutating assertions live in one test so they run sequentially: the
39041    // process-global env var would otherwise race across parallel test threads.
39042    #[test]
39043    fn server_mode_auth_gate_respects_optin() {
39044        // FIXME: Audit that the environment access only happens in single-threaded code.
39045        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
39046        assert!(
39047            refuse_unauthenticated_server(true, false),
39048            "server mode + no key must fail closed by default"
39049        );
39050        // FIXME: Audit that the environment access only happens in single-threaded code.
39051        unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
39052        assert!(
39053            !refuse_unauthenticated_server(true, false),
39054            "explicit opt-in must allow the unauthenticated server"
39055        );
39056        // FIXME: Audit that the environment access only happens in single-threaded code.
39057        unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
39058    }
39059
39060    // ── Health checks & response compression helpers ───────────────────────────
39061
39062    #[test]
39063    fn dir_writable_true_for_temp_dir() {
39064        assert!(dir_writable(&std::env::temp_dir()));
39065    }
39066
39067    #[test]
39068    fn dir_writable_empty_path_is_ok() {
39069        assert!(dir_writable(std::path::Path::new("")));
39070    }
39071
39072    #[test]
39073    fn is_compressible_type_matches_text_and_json() {
39074        assert!(is_compressible_type("text/html; charset=utf-8"));
39075        assert!(is_compressible_type("application/json"));
39076        assert!(is_compressible_type("image/svg+xml"));
39077        assert!(is_compressible_type("application/javascript"));
39078        assert!(!is_compressible_type("application/pdf"));
39079        assert!(!is_compressible_type("application/gzip"));
39080        assert!(!is_compressible_type("image/png"));
39081        assert!(!is_compressible_type(""));
39082    }
39083
39084    #[test]
39085    fn client_accepts_gzip_parses_header() {
39086        let mut h = axum::http::HeaderMap::new();
39087        assert!(!client_accepts_gzip(&h));
39088        h.insert(
39089            header::ACCEPT_ENCODING,
39090            HeaderValue::from_static("br, gzip, deflate"),
39091        );
39092        assert!(client_accepts_gzip(&h));
39093        h.insert(
39094            header::ACCEPT_ENCODING,
39095            HeaderValue::from_static("identity"),
39096        );
39097        assert!(!client_accepts_gzip(&h));
39098    }
39099
39100    #[test]
39101    fn http_timeout_defaults_are_sane() {
39102        // Whatever the ambient env, the timeout is always a positive duration.
39103        assert!(http_timeout() >= std::time::Duration::from_secs(1));
39104    }
39105
39106    #[test]
39107    fn uptime_seconds_is_monotonic_nonpanicking() {
39108        // Anchors the clock and returns a value without panicking.
39109        let _ = uptime_seconds();
39110    }
39111
39112    // ── Zip-slip / path-traversal on tarball extraction ────────────────────────
39113
39114    /// Hand-build a raw USTAR block for `name`/`data`, bypassing `tar::Builder`
39115    /// (which refuses to *write* a `..` path). This lets us feed the *reader* a
39116    /// genuinely malicious archive, which is where the zip-slip guard must hold.
39117    fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
39118        let mut h = [0u8; 512];
39119        let nb = name.as_bytes();
39120        h[..nb.len()].copy_from_slice(nb);
39121        h[100..108].copy_from_slice(b"0000644\0");
39122        h[108..116].copy_from_slice(b"0000000\0");
39123        h[116..124].copy_from_slice(b"0000000\0");
39124        h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
39125        h[136..148].copy_from_slice(b"00000000000\0");
39126        h[156] = b'0'; // typeflag: regular file
39127        h[257..263].copy_from_slice(b"ustar\0");
39128        h[263..265].copy_from_slice(b"00");
39129        for b in &mut h[148..156] {
39130            *b = b' ';
39131        }
39132        let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
39133        h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
39134
39135        let mut out = h.to_vec();
39136        out.extend_from_slice(data);
39137        out.resize(out.len() + (512 - data.len() % 512) % 512, 0); // pad file to 512
39138        out.resize(out.len() + 1024, 0); // two trailing zero blocks
39139        out
39140    }
39141
39142    /// A malicious tar whose entry path escapes the destination via `..` must not
39143    /// write outside the staging directory. Locks in the `tar::Archive::unpack`
39144    /// zip-slip guard as a regression test.
39145    #[tokio::test]
39146    async fn tarball_extraction_blocks_zip_slip() {
39147        use std::io::Write as _;
39148
39149        let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
39150        let staging = base.join("staging");
39151        let tar_gz = base.join("evil.tar.gz");
39152        std::fs::create_dir_all(&base).unwrap();
39153
39154        // Write a gzip-compressed tar whose single entry is "../escaped.txt".
39155        {
39156            let f = std::fs::File::create(&tar_gz).unwrap();
39157            let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
39158            enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
39159                .unwrap();
39160            enc.finish().unwrap().flush().unwrap();
39161        }
39162
39163        // Extraction must not write the escaped file beside the staging directory.
39164        let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
39165
39166        let escaped = base.join("escaped.txt");
39167        assert!(
39168            !escaped.exists(),
39169            "zip-slip entry escaped staging to {}",
39170            escaped.display()
39171        );
39172
39173        let _ = std::fs::remove_dir_all(&base);
39174    }
39175
39176    #[test]
39177    fn size_limit_reader_zero_remaining_returns_error() {
39178        let data = b"hello world";
39179        let mut reader = SizeLimitReader {
39180            inner: &data[..],
39181            remaining: 0,
39182        };
39183        let mut buf = [0u8; 4];
39184        assert!(reader.read(&mut buf).is_err());
39185    }
39186
39187    #[test]
39188    fn size_limit_reader_counts_bytes() {
39189        let data = b"hello world";
39190        let mut reader = SizeLimitReader {
39191            inner: &data[..],
39192            remaining: 5,
39193        };
39194        let mut buf = [0u8; 4];
39195        let n = reader.read(&mut buf).unwrap();
39196        assert_eq!(n, 4);
39197        assert_eq!(reader.remaining, 1);
39198    }
39199
39200    #[test]
39201    fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
39202        let uuid = "12345678-1234-1234-1234-123456789012";
39203        let (id, path) = resolve_or_create_staging(Some(uuid));
39204        assert_eq!(id, uuid);
39205        assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
39206    }
39207
39208    #[test]
39209    fn resolve_or_create_staging_with_none_creates_new() {
39210        let (id1, _) = resolve_or_create_staging(None);
39211        let (id2, _) = resolve_or_create_staging(None);
39212        assert_ne!(id1, id2);
39213    }
39214
39215    #[test]
39216    fn resolve_or_create_staging_with_path_separator_creates_new() {
39217        // "has/slash" contains '/' which is not alphanumeric or '-', so falls to new-id branch
39218        let (id, _) = resolve_or_create_staging(Some("has/slash"));
39219        assert_ne!(id, "has/slash");
39220    }
39221
39222    #[test]
39223    fn auth_lockout_remaining_secs_no_entry_returns_zero() {
39224        use std::net::IpAddr;
39225        use std::str::FromStr;
39226        let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
39227        let ip = IpAddr::from_str("192.168.1.1").unwrap();
39228        assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
39229    }
39230
39231    #[test]
39232    fn is_auth_locked_out_expired_entry_removed() {
39233        use std::net::IpAddr;
39234        use std::str::FromStr;
39235        let limiter = IpRateLimiter::new(
39236            Duration::from_mins(1),
39237            100,
39238            1, // 1 failure triggers lockout
39239            Duration::from_millis(1),
39240        );
39241        let ip = IpAddr::from_str("192.168.1.2").unwrap();
39242        limiter.record_auth_failure(ip);
39243        // Wait for the 1ms window to expire
39244        std::thread::sleep(Duration::from_millis(10));
39245        // Expired entry should be removed, returning false
39246        assert!(!limiter.is_auth_locked_out(ip));
39247    }
39248
39249    #[test]
39250    fn is_auth_locked_out_within_window_returns_true() {
39251        use std::net::IpAddr;
39252        use std::str::FromStr;
39253        let limiter = IpRateLimiter::new(
39254            Duration::from_mins(1),
39255            100,
39256            2, // 2 failures triggers lockout
39257            Duration::from_hours(1),
39258        );
39259        let ip = IpAddr::from_str("192.168.1.3").unwrap();
39260        limiter.record_auth_failure(ip);
39261        limiter.record_auth_failure(ip);
39262        assert!(limiter.is_auth_locked_out(ip));
39263    }
39264
39265    // ── output_folder_hint ───────────────────────────────────────────────────────
39266
39267    #[test]
39268    fn output_folder_hint_strips_json_subdir() {
39269        use std::path::Path;
39270        let path = Path::new("/output/scan1/json/result.json");
39271        let hint = output_folder_hint(path);
39272        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39273    }
39274
39275    #[test]
39276    fn output_folder_hint_strips_html_subdir() {
39277        use std::path::Path;
39278        let path = Path::new("/output/scan1/html/report.html");
39279        let hint = output_folder_hint(path);
39280        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39281    }
39282
39283    #[test]
39284    fn output_folder_hint_strips_pdf_subdir() {
39285        use std::path::Path;
39286        let path = Path::new("/output/scan1/pdf/report.pdf");
39287        let hint = output_folder_hint(path);
39288        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39289    }
39290
39291    #[test]
39292    fn output_folder_hint_strips_excel_subdir() {
39293        use std::path::Path;
39294        let path = Path::new("/output/scan1/excel/report.xlsx");
39295        let hint = output_folder_hint(path);
39296        assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39297    }
39298
39299    #[test]
39300    fn output_folder_hint_flat_layout_returns_direct_parent() {
39301        use std::path::Path;
39302        let path = Path::new("/output/scan1/result.json");
39303        let hint = output_folder_hint(path);
39304        assert!(
39305            hint.ends_with("scan1"),
39306            "expected direct parent, got: {hint}"
39307        );
39308    }
39309
39310    #[test]
39311    fn output_folder_hint_other_subdir_name_not_stripped() {
39312        use std::path::Path;
39313        // "data" is not one of the named artifact subdirs — parent is kept as-is
39314        let path = Path::new("/output/scan1/data/result.json");
39315        let hint = output_folder_hint(path);
39316        assert!(
39317            hint.ends_with("data"),
39318            "non-artifact subdir must not be stripped, got: {hint}"
39319        );
39320    }
39321
39322    // ── find_file_by_ext ─────────────────────────────────────────────────────────
39323
39324    #[test]
39325    fn find_file_by_ext_finds_matching_file() {
39326        let dir = std::env::temp_dir().join("sloc_web_fbe_test");
39327        let _ = fs::create_dir_all(&dir);
39328        let f = dir.join("report.pdf");
39329        let _ = fs::write(&f, b"dummy");
39330        let result = find_file_by_ext(&dir, "pdf");
39331        assert!(result.is_some(), "expected to find report.pdf");
39332        let _ = fs::remove_dir_all(&dir);
39333    }
39334
39335    #[test]
39336    fn find_file_by_ext_returns_none_for_missing_ext() {
39337        let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
39338        let _ = fs::create_dir_all(&dir);
39339        let f = dir.join("report.json");
39340        let _ = fs::write(&f, b"{}");
39341        let result = find_file_by_ext(&dir, "pdf");
39342        assert!(result.is_none());
39343        let _ = fs::remove_dir_all(&dir);
39344    }
39345
39346    #[test]
39347    fn find_file_by_ext_returns_none_for_nonexistent_dir() {
39348        let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
39349        assert!(find_file_by_ext(dir, "json").is_none());
39350    }
39351
39352    // ── collect_result_json_candidates ───────────────────────────────────────────
39353
39354    #[test]
39355    fn collect_result_json_candidates_flat_root() {
39356        let root = std::env::temp_dir().join("sloc_web_crjc_flat");
39357        let _ = fs::create_dir_all(&root);
39358        let _ = fs::write(root.join("result.json"), b"{}");
39359        let candidates = collect_result_json_candidates(&root);
39360        assert!(!candidates.is_empty(), "should find result.json at root");
39361        let _ = fs::remove_dir_all(&root);
39362    }
39363
39364    #[test]
39365    fn collect_result_json_candidates_legacy_subdir() {
39366        let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
39367        let sub = root.join("scanA");
39368        let _ = fs::create_dir_all(&sub);
39369        let _ = fs::write(sub.join("result.json"), b"{}");
39370        let candidates = collect_result_json_candidates(&root);
39371        assert!(
39372            !candidates.is_empty(),
39373            "should find result.json in legacy subdir"
39374        );
39375        let _ = fs::remove_dir_all(&root);
39376    }
39377
39378    #[test]
39379    fn collect_result_json_candidates_structured_json_subdir() {
39380        let root = std::env::temp_dir().join("sloc_web_crjc_struct");
39381        let json_sub = root.join("scanB").join("json");
39382        let _ = fs::create_dir_all(&json_sub);
39383        let _ = fs::write(json_sub.join("result.json"), b"{}");
39384        let candidates = collect_result_json_candidates(&root);
39385        assert!(
39386            !candidates.is_empty(),
39387            "should find result.json inside <subdir>/json/"
39388        );
39389        let _ = fs::remove_dir_all(&root);
39390    }
39391
39392    #[test]
39393    fn collect_result_json_candidates_empty_dir() {
39394        let root = std::env::temp_dir().join("sloc_web_crjc_empty");
39395        let _ = fs::create_dir_all(&root);
39396        let candidates = collect_result_json_candidates(&root);
39397        assert!(candidates.is_empty());
39398        let _ = fs::remove_dir_all(&root);
39399    }
39400}