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 auth;
26pub(crate) mod confluence;
27pub(crate) mod error;
28pub(crate) mod git_browser;
29pub(crate) mod git_webhook;
30pub(crate) mod integrations;
31
32use std::{
33    collections::{HashMap, VecDeque},
34    fmt::Write,
35    fs,
36    net::{IpAddr, SocketAddr},
37    path::{Path, PathBuf},
38    process::Stdio,
39    sync::Arc,
40    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
41};
42
43use anyhow::{Context, Result};
44use askama::Template;
45use axum::{
46    body::Body,
47    extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
48    http::{header, HeaderValue, Request, StatusCode},
49    middleware::{self, Next},
50    response::{Html, IntoResponse, Response},
51    routing::{get, post},
52    Json, Router,
53};
54use serde::{Deserialize, Serialize};
55use tokio::sync::Mutex;
56use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
57
58use sloc_config::{AppConfig, BinaryFileBehavior, MixedLinePolicy};
59use sloc_git::ScheduleStore;
60
61#[derive(Clone)]
62pub(crate) struct CspNonce(pub(crate) String);
63
64static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
65
66use sloc_core::{
67    analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
68    ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
69};
70use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
71const MAX_CONCURRENT_ANALYSES: usize = 4;
72
73/// Windows-only helpers that force the native file-picker dialog into the
74/// foreground instead of appearing minimised behind other windows.
75///
76/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
77/// foreground thread so that windows created on our thread inherit focus; and
78/// (b) spin a polling watcher that finds the dialog by title and calls
79/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
80#[cfg(all(target_os = "windows", feature = "native-dialog"))]
81#[allow(clippy::upper_case_acronyms)]
82mod win_dialog_focus {
83    use std::mem::size_of;
84
85    type HWND = *mut core::ffi::c_void;
86    type DWORD = u32;
87    type UINT = u32;
88    type BOOL = i32;
89
90    // Mirror of FLASHWINFO from winuser.h — field names kept in PascalCase to
91    // match the Win32 ABI layout exactly; the #[allow] suppresses the Rust
92    // naming lint for this one struct.
93    #[repr(C)]
94    #[allow(non_snake_case)]
95    struct FLASHWINFO {
96        cbSize: UINT,
97        hwnd: HWND,
98        dwFlags: DWORD,
99        uCount: UINT,
100        dwTimeout: DWORD,
101    }
102
103    const FLASHW_ALL: DWORD = 0x3;
104    const FLASHW_TIMERNOFG: DWORD = 0xC;
105
106    #[link(name = "user32")]
107    extern "system" {
108        fn GetForegroundWindow() -> HWND;
109        fn SetForegroundWindow(hWnd: HWND) -> BOOL;
110        fn BringWindowToTop(hWnd: HWND) -> BOOL;
111        fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
112        fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
113        fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
114        fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
115    }
116
117    #[link(name = "kernel32")]
118    extern "system" {
119        fn GetCurrentThreadId() -> DWORD;
120    }
121
122    /// Attaches our thread's input to the foreground window's thread so that
123    /// windows created on our thread inherit foreground focus.  Returns the
124    /// foreground thread ID (needed for `detach_from_foreground`), or 0 if
125    /// the thread was already the foreground thread.
126    pub fn attach_to_foreground() -> DWORD {
127        unsafe {
128            let fg_hwnd = GetForegroundWindow();
129            if fg_hwnd.is_null() {
130                return 0;
131            }
132            let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
133            let my_tid = GetCurrentThreadId();
134            if fg_tid == my_tid {
135                return 0;
136            }
137            AttachThreadInput(my_tid, fg_tid, 1);
138            fg_tid
139        }
140    }
141
142    /// Undoes `attach_to_foreground`.
143    pub fn detach_from_foreground(fg_tid: DWORD) {
144        if fg_tid == 0 {
145            return;
146        }
147        unsafe {
148            AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
149        }
150    }
151
152    /// Spawns a short-lived watcher thread that polls for a dialog window
153    /// matching `title` and, once found, forces it to the foreground and
154    /// flashes its taskbar button until the user interacts with it.
155    pub fn flash_dialog_when_ready(title: String) {
156        std::thread::spawn(move || {
157            let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
158            for _ in 0..40 {
159                std::thread::sleep(std::time::Duration::from_millis(80));
160                unsafe {
161                    let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
162                    if !hwnd.is_null() {
163                        SetForegroundWindow(hwnd);
164                        BringWindowToTop(hwnd);
165                        #[allow(non_snake_case)]
166                        FlashWindowEx(&FLASHWINFO {
167                            // size_of returns usize; Win32 struct field is u32 (UINT).
168                            // struct size fits trivially within u32.
169                            #[allow(clippy::cast_possible_truncation)]
170                            cbSize: size_of::<FLASHWINFO>() as UINT,
171                            hwnd,
172                            dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
173                            uCount: 3,
174                            dwTimeout: 0,
175                        });
176                        break;
177                    }
178                }
179            }
180        });
181    }
182}
183
184/// Sliding-window rate limiter keyed by client IP.
185/// Uses only std primitives — no external crate required.
186pub(crate) struct IpRateLimiter {
187    window: Duration,
188    max_requests: usize,
189    pub(crate) auth_lockout_threshold: u32,
190    auth_lockout_window: Duration,
191    state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
192    auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
193}
194
195impl IpRateLimiter {
196    pub(crate) fn new(
197        window: Duration,
198        max_requests: usize,
199        auth_lockout_threshold: u32,
200        auth_lockout_window: Duration,
201    ) -> Self {
202        Self {
203            window,
204            max_requests,
205            auth_lockout_threshold,
206            auth_lockout_window,
207            state: std::sync::Mutex::new(HashMap::new()),
208            auth_failures: std::sync::Mutex::new(HashMap::new()),
209        }
210    }
211
212    // The MutexGuard `state` must live as long as `bucket` borrows from it,
213    // so it cannot be dropped any earlier than the end of the inner block.
214    #[allow(clippy::significant_drop_tightening)]
215    pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
216        let now = Instant::now();
217        let cutoff = now.checked_sub(self.window).unwrap_or(now);
218        let mut state = self
219            .state
220            .lock()
221            .unwrap_or_else(std::sync::PoisonError::into_inner);
222        if state.len() > 10_000 {
223            state.retain(|_, bucket| {
224                while bucket.front().is_some_and(|t| *t <= cutoff) {
225                    bucket.pop_front();
226                }
227                !bucket.is_empty()
228            });
229        }
230        let bucket = state.entry(ip).or_default();
231        while bucket.front().is_some_and(|t| *t <= cutoff) {
232            bucket.pop_front();
233        }
234        if bucket.len() >= self.max_requests {
235            false
236        } else {
237            bucket.push_back(now);
238            true
239        }
240    }
241
242    pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
243        let now = Instant::now();
244        let mut map = self
245            .auth_failures
246            .lock()
247            .unwrap_or_else(std::sync::PoisonError::into_inner);
248        map.entry(ip)
249            .and_modify(|e| {
250                e.0 += 1;
251                e.1 = now;
252            })
253            .or_insert_with(|| (1, now));
254    }
255
256    pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
257        let mut map = self
258            .auth_failures
259            .lock()
260            .unwrap_or_else(std::sync::PoisonError::into_inner);
261        let expired = map
262            .get(&ip)
263            .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
264        if expired {
265            map.remove(&ip);
266            return false;
267        }
268        map.get(&ip)
269            .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
270    }
271
272    pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
273        let map = self
274            .auth_failures
275            .lock()
276            .unwrap_or_else(std::sync::PoisonError::into_inner);
277        map.get(&ip).map_or(0, |e| {
278            self.auth_lockout_window
279                .checked_sub(e.1.elapsed())
280                .map_or(0, |r| r.as_secs())
281        })
282    }
283
284    pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
285        tokio::spawn(async move {
286            let mut interval = tokio::time::interval(Duration::from_mins(1));
287            interval.tick().await; // consume the immediate first tick
288            loop {
289                interval.tick().await;
290                let now = Instant::now();
291                let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
292                {
293                    let mut state = limiter
294                        .state
295                        .lock()
296                        .unwrap_or_else(std::sync::PoisonError::into_inner);
297                    state.retain(|_, bucket| {
298                        while bucket.front().is_some_and(|t| *t <= cutoff) {
299                            bucket.pop_front();
300                        }
301                        !bucket.is_empty()
302                    });
303                }
304                {
305                    let mut auth = limiter
306                        .auth_failures
307                        .lock()
308                        .unwrap_or_else(std::sync::PoisonError::into_inner);
309                    auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
310                }
311            }
312        });
313    }
314}
315
316/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
317#[derive(Clone, Debug, Default)]
318struct RunResultContext {
319    prev_entry: Option<RegistryEntry>,
320    prev_scan_count: usize,
321    project_path: String,
322}
323
324/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
325#[derive(Clone)]
326enum AsyncRunState {
327    Running {
328        started_at: std::time::Instant,
329        cancel_token: Arc<std::sync::atomic::AtomicBool>,
330    },
331    /// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
332    Complete {
333        run_id: String,
334    },
335    Failed {
336        message: String,
337    },
338    Cancelled,
339}
340
341/// A saved scan configuration profile — stores the form parameters so users can
342/// re-run a favourite scan with one click.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344struct ScanProfile {
345    id: String,
346    name: String,
347    created_at: String,
348    /// The raw scan-form parameters serialized as JSON.
349    params: serde_json::Value,
350}
351
352#[derive(Debug, Clone, Default, Serialize, Deserialize)]
353struct ScanProfileStore {
354    profiles: Vec<ScanProfile>,
355}
356
357impl ScanProfileStore {
358    fn load(path: &std::path::Path) -> Self {
359        fs::read_to_string(path)
360            .ok()
361            .and_then(|s| serde_json::from_str(&s).ok())
362            .unwrap_or_default()
363    }
364
365    fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
366        if let Some(parent) = path.parent() {
367            fs::create_dir_all(parent)?;
368        }
369        let json = serde_json::to_string_pretty(self)?;
370        fs::write(path, json)?;
371        Ok(())
372    }
373}
374
375#[derive(Clone)]
376pub(crate) struct AppState {
377    pub(crate) base_config: AppConfig,
378    pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
379    pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
380    pub(crate) registry: Arc<Mutex<ScanRegistry>>,
381    pub(crate) registry_path: PathBuf,
382    pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
383    pub(crate) server_mode: bool,
384    pub(crate) tls_enabled: bool,
385    pub(crate) api_keys: Vec<secrecy::Secret<String>>,
386    pub(crate) rate_limiter: Arc<IpRateLimiter>,
387    pub(crate) trust_proxy: bool,
388    /// Directory where remote repositories are cloned for git-browser scans.
389    pub(crate) git_clones_dir: PathBuf,
390    /// Persisted list of webhook / poll schedules.
391    pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
392    pub(crate) schedules_path: PathBuf,
393    /// Named scan profiles saved by the user via the web UI.
394    pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
395    pub(crate) scan_profiles_path: PathBuf,
396    pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
397    /// Persisted Confluence integration settings.
398    pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
399    pub(crate) confluence_path: PathBuf,
400    /// Directories the user has pinned for auto-scanning of external reports.
401    pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
402    pub(crate) watched_dirs_path: PathBuf,
403}
404
405type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
406
407/// Parameters for the fire-and-forget HTML + PDF background task.
408
409#[derive(Clone, Debug)]
410pub(crate) struct RunArtifacts {
411    output_dir: PathBuf,
412    html_path: Option<PathBuf>,
413    pdf_path: Option<PathBuf>,
414    json_path: Option<PathBuf>,
415    csv_path: Option<PathBuf>,
416    xlsx_path: Option<PathBuf>,
417    scan_config_path: Option<PathBuf>,
418    report_title: String,
419    result_context: RunResultContext,
420}
421
422#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
423fn build_router(state: AppState) -> Router {
424    let protected = Router::new()
425        .route("/", get(splash))
426        .route("/scan-setup", get(scan_setup_handler))
427        .route("/scan", get(index))
428        .route("/analyze", post(analyze_handler))
429        .route("/preview", get(preview_handler))
430        .route("/api/suggest-coverage", get(api_suggest_coverage))
431        .route("/pick-directory", get(pick_directory_handler))
432        .route("/open-path", get(open_path_handler))
433        .route("/pick-file", get(pick_file_handler))
434        .route("/locate-report", post(locate_report_handler))
435        .route("/locate-reports-dir", post(locate_reports_dir_handler))
436        .route("/relocate-scan", post(relocate_scan_handler))
437        .route("/watched-dirs/add", post(add_watched_dir_handler))
438        .route("/watched-dirs/remove", post(remove_watched_dir_handler))
439        .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
440        .route("/view-reports", get(history_handler))
441        .route("/compare-scans", get(compare_select_handler))
442        .route("/compare", get(compare_handler))
443        .route("/images/{folder}/{file}", get(image_handler))
444        .route("/runs/{artifact}/{run_id}", get(artifact_handler))
445        .route("/api/metrics/latest", get(api_metrics_latest_handler))
446        .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
447        .route("/api/metrics/history", get(api_metrics_history_handler))
448        .route(
449            "/api/metrics/submodules",
450            get(api_metrics_submodules_handler),
451        )
452        .route("/api/ingest", post(api_ingest_handler))
453        .route("/api/project-history", get(project_history_handler))
454        .route("/trend-reports", get(trend_report_handler))
455        .route("/test-metrics", get(test_metrics_handler))
456        .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
457        .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
458        .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
459        .route("/runs/result/{run_id}", get(async_run_result_handler))
460        .route("/embed/summary", get(embed_handler))
461        // ── Git browser ────────────────────────────────────────────────────────
462        .route("/git-browser", get(git_browser::git_browser_handler))
463        .route("/api/git/refs", get(git_browser::api_list_refs))
464        .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
465        .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
466        // ── Config export / import ─────────────────────────────────────────────
467        .route("/export-config", get(export_config_handler))
468        .route("/import-config", post(import_config_handler))
469        // ── Scan profiles ──────────────────────────────────────────────────────
470        .route("/api/scan-profiles", get(api_list_scan_profiles))
471        .route("/api/scan-profiles", post(api_save_scan_profile))
472        .route(
473            "/api/scan-profiles/{id}",
474            axum::routing::delete(api_delete_scan_profile),
475        )
476        // ── Integrations (webhooks + Confluence) ──────────────────────────────
477        .route("/integrations", get(integrations::integrations_handler))
478        .route(
479            "/webhook-setup",
480            get(|| async { axum::response::Redirect::permanent("/integrations#webhooks") }),
481        )
482        .route(
483            "/confluence-setup",
484            get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
485        )
486        .route("/api/schedules", get(git_webhook::api_list_schedules))
487        .route("/api/schedules", post(git_webhook::api_create_schedule))
488        .route(
489            "/api/schedules",
490            axum::routing::delete(git_webhook::api_delete_schedule),
491        )
492        .route(
493            "/api/confluence/config",
494            get(confluence::api_get_confluence_config),
495        )
496        .route(
497            "/api/confluence/config",
498            post(confluence::api_save_confluence_config),
499        )
500        .route(
501            "/api/confluence/test",
502            post(confluence::api_test_confluence),
503        )
504        .route(
505            "/api/confluence/post",
506            post(confluence::api_post_to_confluence),
507        )
508        .route(
509            "/api/confluence/wiki-markup",
510            get(confluence::api_wiki_markup),
511        )
512        // ── REST API reference page ────────────────────────────────────────────
513        .route("/api-docs", get(api_docs_handler))
514        .route_layer(middleware::from_fn_with_state(
515            state.clone(),
516            auth::require_api_key,
517        ));
518
519    protected
520        .route("/healthz", get(healthz))
521        .route("/badge/{metric}", get(badge_handler))
522        .route("/static/chart.js", get(chart_js_handler))
523        .route("/auth/login", get(auth::auth_login_get))
524        .route("/auth/login", post(auth::auth_login_post))
525        // Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
526        .route("/webhooks/github", post(git_webhook::handle_github_webhook))
527        .route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
528        .route(
529            "/webhooks/bitbucket",
530            post(git_webhook::handle_bitbucket_webhook),
531        )
532        .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
533        .layer(middleware::from_fn_with_state(
534            state.clone(),
535            add_security_headers,
536        ))
537        .layer(build_cors_layer(state.server_mode))
538        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
539        .with_state(state)
540}
541
542/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
543pub fn make_test_router() -> Router {
544    let tmp = std::env::temp_dir().join("sloc_test");
545    let state = AppState {
546        base_config: AppConfig::default(),
547        artifacts: Arc::new(Mutex::new(HashMap::new())),
548        async_runs: Arc::new(Mutex::new(HashMap::new())),
549        registry: Arc::new(Mutex::new(ScanRegistry::default())),
550        registry_path: tmp.join("registry.json"),
551        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
552        server_mode: false,
553        tls_enabled: false,
554        api_keys: vec![],
555        rate_limiter: Arc::new(IpRateLimiter::new(
556            Duration::from_mins(1),
557            600,
558            10,
559            Duration::from_hours(1),
560        )),
561        trust_proxy: false,
562        git_clones_dir: tmp.join("git-clones"),
563        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
564        schedules_path: tmp.join("schedules.json"),
565        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
566        scan_profiles_path: tmp.join("scan_profiles.json"),
567        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
568        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
569        confluence_path: tmp.join("confluence_config.json"),
570        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
571        watched_dirs_path: tmp.join("watched_dirs.json"),
572    };
573    build_router(state)
574}
575
576/// Test router with one API key pre-loaded. Used by auth integration tests.
577pub fn make_test_router_with_key(api_key: &str) -> Router {
578    let tmp = std::env::temp_dir().join("sloc_test_key");
579    let state = AppState {
580        base_config: AppConfig::default(),
581        artifacts: Arc::new(Mutex::new(HashMap::new())),
582        async_runs: Arc::new(Mutex::new(HashMap::new())),
583        registry: Arc::new(Mutex::new(ScanRegistry::default())),
584        registry_path: tmp.join("registry.json"),
585        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
586        server_mode: false,
587        tls_enabled: false,
588        api_keys: vec![secrecy::Secret::new(api_key.to_owned())],
589        rate_limiter: Arc::new(IpRateLimiter::new(
590            Duration::from_mins(1),
591            600,
592            10,
593            Duration::from_hours(1),
594        )),
595        trust_proxy: false,
596        git_clones_dir: tmp.join("git-clones"),
597        schedules: Arc::new(Mutex::new(ScheduleStore::default())),
598        schedules_path: tmp.join("schedules.json"),
599        scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
600        scan_profiles_path: tmp.join("scan_profiles.json"),
601        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
602        confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
603        confluence_path: tmp.join("confluence_config.json"),
604        watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
605        watched_dirs_path: tmp.join("watched_dirs.json"),
606    };
607    build_router(state)
608}
609
610/// # Errors
611///
612/// Returns an error if the server fails to bind to the configured address or
613/// if the TLS configuration cannot be loaded.
614///
615/// # Panics
616///
617/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
618// The function coordinates TLS setup, router construction, and async listener setup in one
619// place; splitting it further would require passing many state values across function boundaries.
620#[allow(clippy::too_many_lines)]
621pub async fn serve(config: AppConfig) -> Result<()> {
622    let bind_address = config.web.bind_address.clone();
623    let server_mode = config.web.server_mode;
624    let output_root = resolve_output_root(None);
625    // SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
626    let registry_path = std::env::var("SLOC_REGISTRY_PATH")
627        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
628    let mut registry = ScanRegistry::load(&registry_path);
629    registry.prune_stale();
630    let _ = registry.save(&registry_path);
631
632    let api_keys: Vec<secrecy::Secret<String>> = std::env::var("SLOC_API_KEYS")
633        .or_else(|_| std::env::var("SLOC_API_KEY"))
634        .unwrap_or_default()
635        .split(',')
636        .map(str::trim)
637        .filter(|s| !s.is_empty())
638        .map(|s| secrecy::Secret::new(s.to_owned()))
639        .collect();
640    if server_mode && api_keys.is_empty() {
641        println!(
642            "WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
643             unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
644        );
645    }
646
647    let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
648    let tls_key = std::env::var("SLOC_TLS_KEY").ok();
649    let tls_enabled = tls_cert.is_some() && tls_key.is_some();
650    if server_mode && !tls_enabled {
651        println!(
652            "WARNING: TLS is not configured. Traffic is cleartext. \
653             Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
654             or terminate TLS at a reverse proxy (nginx, caddy)."
655        );
656    }
657    if server_mode {
658        println!(
659            "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
660             to restrict cross-origin access (comma-separated)."
661        );
662    }
663    let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
664    if trust_proxy {
665        println!(
666            "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
667             Only set this when oxide-sloc is behind a trusted reverse proxy."
668        );
669    }
670
671    let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
672        .ok()
673        .and_then(|v| v.parse::<u32>().ok())
674        .unwrap_or(10);
675    let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
676        .ok()
677        .and_then(|v| v.parse::<u64>().ok())
678        .unwrap_or(3600);
679    // 600 req/min per IP across all routes (10/sec — suits local/air-gapped use).
680    let rate_limiter = Arc::new(IpRateLimiter::new(
681        Duration::from_mins(1),
682        600,
683        auth_lockout_threshold,
684        Duration::from_secs(auth_lockout_secs),
685    ));
686    IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
687
688    let git_clones_dir = resolve_git_clones_dir(&output_root);
689    let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
690        .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
691    let schedules = ScheduleStore::load(&schedules_path);
692    let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
693        .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
694    let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
695    let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
696        |_| output_root.join("confluence_config.json"),
697        PathBuf::from,
698    );
699    let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
700    let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
701        .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
702    let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
703
704    let state = AppState {
705        base_config: config,
706        artifacts: Arc::new(Mutex::new(HashMap::new())),
707        async_runs: Arc::new(Mutex::new(HashMap::new())),
708        registry: Arc::new(Mutex::new(registry)),
709        registry_path,
710        analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
711        server_mode,
712        tls_enabled,
713        api_keys,
714        rate_limiter,
715        trust_proxy,
716        git_clones_dir,
717        schedules: Arc::new(Mutex::new(schedules)),
718        schedules_path,
719        scan_profiles: Arc::new(Mutex::new(scan_profiles)),
720        scan_profiles_path,
721        sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
722        confluence: Arc::new(Mutex::new(confluence)),
723        confluence_path,
724        watched_dirs: Arc::new(Mutex::new(watched_dirs)),
725        watched_dirs_path,
726    };
727
728    restart_poll_schedules(&state).await;
729
730    let app = build_router(state.clone());
731
732    // Try the configured port first, then step up through a few alternatives.
733    // On Windows, a killed process can leave its LISTEN socket as an unkillable
734    // kernel zombie (visible in netstat but owned by no living process).  Rather
735    // than failing, we auto-select the next free port and tell the user.
736    let preferred: SocketAddr = bind_address
737        .parse()
738        .with_context(|| format!("invalid bind address: {bind_address}"))?;
739    let (listener, addr) = {
740        let candidates = (0u16..=9).map(|offset| {
741            let mut a = preferred;
742            a.set_port(preferred.port().saturating_add(offset));
743            a
744        });
745        let mut found = None;
746        for candidate in candidates {
747            if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
748                found = Some((l, candidate));
749                break;
750            }
751        }
752        found.ok_or_else(|| {
753            anyhow::anyhow!(
754                "failed to bind local web UI on {} (tried ports {}-{}): all in use",
755                bind_address,
756                preferred.port(),
757                preferred.port().saturating_add(9)
758            )
759        })?
760    };
761    if addr != preferred {
762        eprintln!(
763            "NOTE: port {} is blocked by a system socket (Windows zombie); \
764             using {} instead.",
765            preferred.port(),
766            addr.port()
767        );
768    }
769
770    if tls_enabled {
771        let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
772        let key_path = tls_key.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
773        let tls_config = build_tls_config(&cert_path, &key_path)
774            .context("failed to load TLS certificate/key")?;
775        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
776
777        let url = format!("https://{addr}/");
778        println!("OxideSLOC server running at {url} (TLS)");
779        println!("Use Ctrl+C to stop.");
780
781        return serve_tls(listener, app, acceptor, server_mode).await;
782    }
783
784    let url = format!("http://{addr}/");
785    log_startup_url(&url, server_mode);
786
787    axum::serve(
788        listener,
789        app.into_make_service_with_connect_info::<SocketAddr>(),
790    )
791    .with_graceful_shutdown(shutdown_signal(server_mode))
792    .await
793    .context("web server terminated unexpectedly")
794}
795
796/// Discover the primary non-loopback IPv4 address by asking the OS which
797/// outbound interface it would use to reach a public address.  No packets are
798/// sent — the UDP socket is only used to query the routing table.
799fn primary_lan_ip() -> Option<String> {
800    let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
801    socket.connect("8.8.8.8:80").ok()?;
802    let addr = socket.local_addr().ok()?;
803    let ip = addr.ip();
804    if ip.is_loopback() {
805        return None;
806    }
807    Some(ip.to_string())
808}
809
810/// Print the startup URL and, in local mode, open the browser and schedule it.
811fn log_startup_url(url: &str, server_mode: bool) {
812    if server_mode {
813        println!("OxideSLOC server running at {url}");
814        println!("Use Ctrl+C to stop.");
815    } else {
816        println!("OxideSLOC local web UI running at {url}");
817        println!("Press Ctrl+C to stop the server.");
818        let open_url = url.to_owned();
819        tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
820    }
821}
822
823/// Open the given URL in the default system browser.
824fn open_browser_tab(url: &str) {
825    #[cfg(target_os = "windows")]
826    let _ = std::process::Command::new("cmd")
827        .args(["/c", "start", "", url])
828        .stdout(Stdio::null())
829        .stderr(Stdio::null())
830        .spawn();
831    #[cfg(target_os = "macos")]
832    let _ = std::process::Command::new("open")
833        .arg(url)
834        .stdout(Stdio::null())
835        .stderr(Stdio::null())
836        .spawn();
837    #[cfg(target_os = "linux")]
838    let _ = std::process::Command::new("xdg-open")
839        .arg(url)
840        .stdout(Stdio::null())
841        .stderr(Stdio::null())
842        .spawn();
843}
844
845/// Graceful-shutdown future: resolves on Ctrl-C.
846async fn shutdown_signal(server_mode: bool) {
847    if tokio::signal::ctrl_c().await.is_ok() {
848        println!();
849        if server_mode {
850            println!("Shutting down OxideSLOC server...");
851        } else {
852            println!("Shutting down OxideSLOC local web UI...");
853        }
854        println!("Server stopped cleanly.");
855    }
856}
857
858/// Load a rustls `ServerConfig` from PEM certificate and key files.
859fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
860    use rustls_pemfile::{certs, private_key};
861    use std::io::BufReader;
862
863    let cert_bytes =
864        fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
865    let key_bytes =
866        fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
867
868    let cert_chain: Vec<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
869        .collect::<std::result::Result<_, _>>()
870        .context("failed to parse TLS certificates")?;
871
872    let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
873        .context("failed to parse TLS private key")?
874        .ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
875
876    rustls::ServerConfig::builder()
877        .with_no_client_auth()
878        .with_single_cert(cert_chain, key)
879        .context("failed to build TLS server config")
880}
881
882/// Accept loop with TLS termination using tokio-rustls + hyper-util.
883async fn serve_tls(
884    listener: tokio::net::TcpListener,
885    app: Router,
886    acceptor: tokio_rustls::TlsAcceptor,
887    server_mode: bool,
888) -> Result<()> {
889    use hyper_util::rt::{TokioExecutor, TokioIo};
890    use hyper_util::server::conn::auto::Builder as ConnBuilder;
891    use hyper_util::service::TowerToHyperService;
892    use tower::{Service, ServiceExt};
893
894    let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
895
896    loop {
897        tokio::select! {
898            biased;
899            _ = tokio::signal::ctrl_c() => {
900                println!();
901                if server_mode {
902                    println!("Shutting down OxideSLOC server...");
903                } else {
904                    println!("Shutting down OxideSLOC local web UI...");
905                }
906                println!("Server stopped cleanly.");
907                return Ok(());
908            }
909            result = listener.accept() => {
910                let (tcp, peer_addr) = result.context("TLS accept failed")?;
911                let acceptor = acceptor.clone();
912                let mut factory = make_svc.clone();
913
914                tokio::spawn(async move {
915                    let tls = match acceptor.accept(tcp).await {
916                        Ok(s) => s,
917                        Err(e) => {
918                            eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
919                            return;
920                        }
921                    };
922                    let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
923                        Ok(f) => match Service::call(f, peer_addr).await {
924                            Ok(s) => s,
925                            Err(_) => return,
926                        },
927                        Err(_) => return,
928                    };
929                    let io = TokioIo::new(tls);
930                    if let Err(e) = ConnBuilder::new(TokioExecutor::new())
931                        .serve_connection(io, TowerToHyperService::new(svc))
932                        .await
933                    {
934                        eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
935                    }
936                });
937            }
938        }
939    }
940}
941
942// auth moved to auth.rs
943
944fn build_cors_layer(server_mode: bool) -> CorsLayer {
945    if server_mode {
946        let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
947            .unwrap_or_default()
948            .split(',')
949            .filter(|s| !s.is_empty())
950            .filter_map(|s| s.trim().parse().ok())
951            .collect();
952        if allowed.is_empty() {
953            return CorsLayer::new();
954        }
955        CorsLayer::new()
956            .allow_origin(AllowOrigin::list(allowed))
957            .allow_methods(AllowMethods::list([
958                axum::http::Method::GET,
959                axum::http::Method::POST,
960            ]))
961            .allow_headers(AllowHeaders::list([
962                axum::http::header::AUTHORIZATION,
963                axum::http::header::CONTENT_TYPE,
964            ]))
965    } else {
966        CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
967            let s = origin.to_str().unwrap_or("");
968            s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
969        }))
970    }
971}
972
973async fn add_security_headers(
974    State(state): State<AppState>,
975    mut req: Request<Body>,
976    next: Next,
977) -> Response {
978    let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
979    req.extensions_mut().insert(CspNonce(nonce.clone()));
980    let mut resp = next.run(req).await;
981    let h = resp.headers_mut();
982    h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
983    h.insert(
984        "X-Content-Type-Options",
985        HeaderValue::from_static("nosniff"),
986    );
987    h.insert(
988        "Referrer-Policy",
989        HeaderValue::from_static("strict-origin-when-cross-origin"),
990    );
991    let csp = format!(
992        "default-src 'self'; \
993         style-src 'self' 'unsafe-inline'; \
994         img-src 'self' data: blob:; \
995         script-src 'self' 'nonce-{nonce}'; \
996         font-src 'self' data:; \
997         object-src 'none'; \
998         frame-ancestors 'none'"
999    );
1000    h.insert(
1001        "Content-Security-Policy",
1002        HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1003            HeaderValue::from_static(
1004                "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1005            )
1006        }),
1007    );
1008    h.insert(
1009        "X-Permitted-Cross-Domain-Policies",
1010        HeaderValue::from_static("none"),
1011    );
1012    h.insert(
1013        "Permissions-Policy",
1014        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1015    );
1016    h.insert(
1017        "Cross-Origin-Opener-Policy",
1018        HeaderValue::from_static("same-origin"),
1019    );
1020    h.insert(
1021        "Cross-Origin-Resource-Policy",
1022        HeaderValue::from_static("same-origin"),
1023    );
1024    if state.tls_enabled {
1025        h.insert(
1026            "Strict-Transport-Security",
1027            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1028        );
1029    }
1030    resp
1031}
1032
1033async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
1034    let ip = req
1035        .extensions()
1036        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1037        .map(|c| c.0.ip())
1038        .or_else(|| {
1039            if state.trust_proxy {
1040                req.headers()
1041                    .get("X-Forwarded-For")
1042                    .and_then(|v| v.to_str().ok())
1043                    .and_then(|s| s.split(',').next())
1044                    .and_then(|s| s.trim().parse::<IpAddr>().ok())
1045            } else {
1046                None
1047            }
1048        })
1049        .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
1050
1051    if !state.rate_limiter.is_allowed(ip) {
1052        tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
1053            path = %req.uri().path(), "Rate limit exceeded");
1054        return (
1055            StatusCode::TOO_MANY_REQUESTS,
1056            [(header::RETRY_AFTER, "60")],
1057            "429 Too Many Requests\n",
1058        )
1059            .into_response();
1060    }
1061    next.run(req).await
1062}
1063
1064async fn splash(
1065    State(state): State<AppState>,
1066    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1067) -> impl IntoResponse {
1068    let lan_ip = if state.server_mode {
1069        primary_lan_ip()
1070    } else {
1071        None
1072    };
1073    let port = state
1074        .base_config
1075        .web
1076        .bind_address
1077        .rsplit(':')
1078        .next()
1079        .and_then(|p| p.parse::<u16>().ok())
1080        .unwrap_or(4317);
1081    let template = SplashTemplate {
1082        csp_nonce,
1083        server_mode: state.server_mode,
1084        lan_ip,
1085        port,
1086        version: env!("CARGO_PKG_VERSION"),
1087    };
1088    Html(
1089        template
1090            .render()
1091            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1092    )
1093}
1094
1095async fn index(
1096    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1097    Query(query): Query<IndexQuery>,
1098) -> impl IntoResponse {
1099    let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
1100        let policy = query
1101            .mixed_line_policy
1102            .unwrap_or_else(|| "code_only".to_string());
1103        let behavior = query
1104            .binary_file_behavior
1105            .unwrap_or_else(|| "skip".to_string());
1106        let cfg = ScanConfig {
1107            oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
1108            path: query.path.unwrap_or_default(),
1109            include_globs: query.include_globs.unwrap_or_default(),
1110            exclude_globs: query.exclude_globs.unwrap_or_default(),
1111            submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
1112            mixed_line_policy: policy,
1113            python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
1114                != Some("off"),
1115            generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
1116            minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
1117            vendor_directory_detection: query.vendor_directory_detection.as_deref()
1118                != Some("disabled"),
1119            include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
1120            binary_file_behavior: behavior,
1121            output_dir: query.output_dir.unwrap_or_default(),
1122            report_title: query.report_title.unwrap_or_default(),
1123            generate_html: query.generate_html.as_deref() != Some("off"),
1124            generate_pdf: query.generate_pdf.as_deref() == Some("on"),
1125        };
1126        serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
1127    } else {
1128        "{}".to_string()
1129    };
1130
1131    let git_repo = query.git_repo.unwrap_or_default();
1132    let git_ref = query.git_ref.unwrap_or_default();
1133
1134    let git_label = make_git_label(&git_repo, &git_ref);
1135    let git_output_dir = if git_label.is_empty() {
1136        String::new()
1137    } else {
1138        desktop_dir().join(&git_label).display().to_string()
1139    };
1140    let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
1141    let git_output_dir_json =
1142        serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
1143
1144    let template = IndexTemplate {
1145        version: env!("CARGO_PKG_VERSION"),
1146        prefill_json,
1147        csp_nonce,
1148        git_repo,
1149        git_ref,
1150        git_label_json,
1151        git_output_dir_json,
1152    };
1153
1154    Html(
1155        template
1156            .render()
1157            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1158    )
1159}
1160
1161async fn scan_setup_handler(
1162    State(state): State<AppState>,
1163    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1164) -> impl IntoResponse {
1165    let recent_scans_json = {
1166        let arr: Vec<serde_json::Value> = {
1167            let reg = state.registry.lock().await;
1168            reg.entries
1169                .iter()
1170                .rev()
1171                .take(6)
1172                .map(|e| {
1173                    let run_dir = e
1174                        .html_path
1175                        .as_ref()
1176                        .or(e.json_path.as_ref())
1177                        .and_then(|p| p.parent().map(PathBuf::from));
1178                    let config_val: Option<serde_json::Value> = run_dir
1179                        .and_then(|d| find_scan_config_in_dir(&d))
1180                        .and_then(|p| fs::read_to_string(&p).ok())
1181                        .and_then(|s| serde_json::from_str(&s).ok());
1182                    serde_json::json!({
1183                        "project_label": e.project_label,
1184                        "timestamp": fmt_la_time(e.timestamp_utc),
1185                        "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
1186                        "config": config_val,
1187                    })
1188                })
1189                .collect()
1190        };
1191        serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
1192    };
1193
1194    let template = ScanSetupTemplate {
1195        version: env!("CARGO_PKG_VERSION"),
1196        recent_scans_json,
1197        csp_nonce,
1198    };
1199    Html(
1200        template
1201            .render()
1202            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
1203    )
1204}
1205
1206async fn healthz() -> &'static str {
1207    "ok"
1208}
1209
1210async fn api_docs_handler(
1211    State(state): State<AppState>,
1212    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1213) -> impl IntoResponse {
1214    let has_api_key = !state.api_keys.is_empty();
1215    Html(
1216        ApiDocsTemplate {
1217            has_api_key,
1218            csp_nonce,
1219            version: env!("CARGO_PKG_VERSION"),
1220        }
1221        .render()
1222        .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
1223    )
1224}
1225
1226async fn chart_js_handler() -> impl IntoResponse {
1227    (
1228        [(
1229            header::CONTENT_TYPE,
1230            "application/javascript; charset=utf-8",
1231        )],
1232        CHART_JS,
1233    )
1234}
1235
1236#[derive(Debug, Deserialize)]
1237struct AnalyzeForm {
1238    path: String,
1239    git_repo: Option<String>,
1240    git_ref: Option<String>,
1241    mixed_line_policy: Option<MixedLinePolicy>,
1242    python_docstrings_as_comments: Option<String>,
1243    generated_file_detection: Option<String>,
1244    minified_file_detection: Option<String>,
1245    vendor_directory_detection: Option<String>,
1246    include_lockfiles: Option<String>,
1247    binary_file_behavior: Option<BinaryFileBehavior>,
1248    output_dir: Option<String>,
1249    report_title: Option<String>,
1250    report_header_footer: Option<String>,
1251    generate_html: Option<String>,
1252    generate_pdf: Option<String>,
1253    include_globs: Option<String>,
1254    exclude_globs: Option<String>,
1255    submodule_breakdown: Option<String>,
1256    coverage_file: Option<String>,
1257}
1258
1259#[allow(clippy::struct_excessive_bools)]
1260#[derive(Debug, Serialize, Deserialize, Clone)]
1261struct ScanConfig {
1262    oxide_sloc_version: String,
1263    path: String,
1264    include_globs: String,
1265    exclude_globs: String,
1266    submodule_breakdown: bool,
1267    mixed_line_policy: String,
1268    python_docstrings_as_comments: bool,
1269    generated_file_detection: bool,
1270    minified_file_detection: bool,
1271    vendor_directory_detection: bool,
1272    include_lockfiles: bool,
1273    binary_file_behavior: String,
1274    output_dir: String,
1275    report_title: String,
1276    generate_html: bool,
1277    generate_pdf: bool,
1278}
1279
1280#[derive(Debug, Deserialize, Default)]
1281struct IndexQuery {
1282    path: Option<String>,
1283    include_globs: Option<String>,
1284    exclude_globs: Option<String>,
1285    submodule_breakdown: Option<String>,
1286    mixed_line_policy: Option<String>,
1287    python_docstrings_as_comments: Option<String>,
1288    generated_file_detection: Option<String>,
1289    minified_file_detection: Option<String>,
1290    vendor_directory_detection: Option<String>,
1291    include_lockfiles: Option<String>,
1292    binary_file_behavior: Option<String>,
1293    output_dir: Option<String>,
1294    report_title: Option<String>,
1295    generate_html: Option<String>,
1296    generate_pdf: Option<String>,
1297    prefilled: Option<String>,
1298    git_repo: Option<String>,
1299    git_ref: Option<String>,
1300}
1301
1302#[derive(Debug, Deserialize)]
1303struct PreviewQuery {
1304    path: Option<String>,
1305    include_globs: Option<String>,
1306    exclude_globs: Option<String>,
1307}
1308
1309#[cfg(feature = "native-dialog")]
1310#[derive(Debug, Deserialize)]
1311struct PickDirectoryQuery {
1312    kind: Option<String>,
1313    current: Option<String>,
1314}
1315
1316#[cfg(not(feature = "native-dialog"))]
1317#[derive(Debug, Deserialize)]
1318struct PickDirectoryQuery {}
1319
1320#[derive(Debug, Deserialize, Default)]
1321struct ArtifactQuery {
1322    download: Option<String>,
1323}
1324
1325#[cfg(feature = "native-dialog")]
1326#[derive(Debug, Serialize)]
1327struct PickDirectoryResponse {
1328    selected_path: Option<String>,
1329    cancelled: bool,
1330}
1331
1332#[cfg(feature = "native-dialog")]
1333async fn pick_directory_handler(
1334    State(state): State<AppState>,
1335    Query(query): Query<PickDirectoryQuery>,
1336) -> Response {
1337    if state.server_mode {
1338        return StatusCode::NOT_FOUND.into_response();
1339    }
1340
1341    let is_coverage = query.kind.as_deref() == Some("coverage");
1342    let title = match query.kind.as_deref() {
1343        Some("output") => "Select output directory",
1344        Some("reports") => "Select folder containing saved reports",
1345        Some("coverage") => "Select LCOV coverage file",
1346        _ => "Select project directory",
1347    }
1348    .to_owned();
1349    let current = query.current.clone();
1350
1351    let picked = tokio::task::spawn_blocking(move || {
1352        // Windows: attach to the foreground thread so the dialog inherits focus,
1353        // and kick off a watcher that flashes the dialog once it appears.
1354        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1355        let fg_tid = win_dialog_focus::attach_to_foreground();
1356        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1357        win_dialog_focus::flash_dialog_when_ready(title.clone());
1358
1359        let mut dialog = rfd::FileDialog::new().set_title(&title);
1360        if let Some(current) = current.as_deref() {
1361            let resolved = resolve_input_path(current);
1362            let seed = if resolved.is_dir() {
1363                Some(resolved)
1364            } else {
1365                resolved.parent().map(Path::to_path_buf)
1366            };
1367            if let Some(seed_dir) = seed.filter(|p| p.exists()) {
1368                dialog = dialog.set_directory(seed_dir);
1369            }
1370        }
1371        let result = if is_coverage {
1372            dialog
1373                .add_filter(
1374                    "Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
1375                    &["info", "lcov", "xml"],
1376                )
1377                .pick_file()
1378        } else {
1379            dialog.pick_folder()
1380        };
1381
1382        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1383        win_dialog_focus::detach_from_foreground(fg_tid);
1384
1385        result
1386    })
1387    .await
1388    .unwrap_or(None);
1389
1390    Json(PickDirectoryResponse {
1391        selected_path: picked.as_ref().map(|p| display_path(p)),
1392        cancelled: picked.is_none(),
1393    })
1394    .into_response()
1395}
1396
1397#[cfg(not(feature = "native-dialog"))]
1398async fn pick_directory_handler(
1399    State(_state): State<AppState>,
1400    Query(_query): Query<PickDirectoryQuery>,
1401) -> Response {
1402    StatusCode::NOT_FOUND.into_response()
1403}
1404
1405#[cfg(feature = "native-dialog")]
1406async fn pick_file_handler(State(state): State<AppState>) -> Response {
1407    if state.server_mode {
1408        return StatusCode::NOT_FOUND.into_response();
1409    }
1410    let picked = tokio::task::spawn_blocking(|| {
1411        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1412        let fg_tid = win_dialog_focus::attach_to_foreground();
1413        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1414        win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
1415
1416        let result = rfd::FileDialog::new()
1417            .set_title("Select HTML report")
1418            .add_filter("HTML report", &["html"])
1419            .pick_file();
1420
1421        #[cfg(all(target_os = "windows", feature = "native-dialog"))]
1422        win_dialog_focus::detach_from_foreground(fg_tid);
1423
1424        result
1425    })
1426    .await
1427    .unwrap_or(None);
1428    Json(PickDirectoryResponse {
1429        selected_path: picked.as_ref().map(|p| display_path(p)),
1430        cancelled: picked.is_none(),
1431    })
1432    .into_response()
1433}
1434
1435#[cfg(not(feature = "native-dialog"))]
1436async fn pick_file_handler(State(_state): State<AppState>) -> Response {
1437    StatusCode::NOT_FOUND.into_response()
1438}
1439
1440#[derive(Deserialize)]
1441struct LocateReportForm {
1442    file_path: String,
1443}
1444
1445/// Render a view-reports error page and return it as a `Response`.
1446fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
1447    let html = ErrorTemplate {
1448        message: message.into(),
1449        last_report_url: Some("/view-reports".to_string()),
1450        last_report_label: Some("View Reports".to_string()),
1451        csp_nonce: csp_nonce.to_owned(),
1452    }
1453    .render()
1454    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1455    Html(html).into_response()
1456}
1457
1458/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
1459fn registry_entry_from_run(
1460    run: &AnalysisRun,
1461    json_path: PathBuf,
1462    html_path: PathBuf,
1463) -> RegistryEntry {
1464    let project_label = run.input_roots.first().map_or_else(
1465        || "Unknown Project".to_string(),
1466        |r| sanitize_project_label(r),
1467    );
1468    RegistryEntry {
1469        run_id: run.tool.run_id.clone(),
1470        timestamp_utc: run.tool.timestamp_utc,
1471        project_label,
1472        input_roots: run.input_roots.clone(),
1473        json_path: Some(json_path),
1474        html_path: Some(html_path),
1475        pdf_path: None,
1476        summary: ScanSummarySnapshot {
1477            files_analyzed: run.summary_totals.files_analyzed,
1478            files_skipped: run.summary_totals.files_skipped,
1479            total_physical_lines: run.summary_totals.total_physical_lines,
1480            code_lines: run.summary_totals.code_lines,
1481            comment_lines: run.summary_totals.comment_lines,
1482            blank_lines: run.summary_totals.blank_lines,
1483            functions: run.summary_totals.functions,
1484            classes: run.summary_totals.classes,
1485            variables: run.summary_totals.variables,
1486            imports: run.summary_totals.imports,
1487            test_count: run.summary_totals.test_count,
1488        },
1489        csv_path: None,
1490        xlsx_path: None,
1491        git_branch: None,
1492        git_commit: None,
1493        git_author: None,
1494        git_tags: None,
1495        git_nearest_tag: None,
1496        git_commit_date: None,
1497    }
1498}
1499
1500/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
1501/// immediately without requiring a server restart.
1502pub(crate) async fn register_artifacts_in_registry(
1503    state: &AppState,
1504    label: &str,
1505    run: &AnalysisRun,
1506    artifacts: &RunArtifacts,
1507) {
1508    let Some(json_path) = artifacts.json_path.clone() else {
1509        return;
1510    };
1511    let Some(html_path) = artifacts.html_path.clone() else {
1512        return;
1513    };
1514    let mut entry = registry_entry_from_run(run, json_path, html_path);
1515    entry.project_label = label.to_owned();
1516    let mut reg = state.registry.lock().await;
1517    reg.add_entry(entry);
1518    let _ = reg.save(&state.registry_path);
1519}
1520
1521/// Validate the locate-report form: check extension, resolve the canonical path, enforce
1522/// server-mode root restriction, and extract the parent directory.
1523///
1524/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
1525#[allow(clippy::result_large_err)]
1526fn validate_locate_request(
1527    state: &AppState,
1528    file_path: &str,
1529    csp_nonce: &str,
1530) -> Result<(PathBuf, PathBuf), Response> {
1531    let file_ext = Path::new(file_path)
1532        .extension()
1533        .and_then(|e| e.to_str())
1534        .unwrap_or("")
1535        .to_ascii_lowercase();
1536    if file_ext != "html" {
1537        return Err(locate_report_error(
1538            "Only .html report files can be located via this form.",
1539            csp_nonce,
1540        ));
1541    }
1542    let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
1543        Ok(p) => strip_unc_prefix(p),
1544        Err(_) => {
1545            return Err(locate_report_error(
1546                "Report file not found or path is invalid.",
1547                csp_nonce,
1548            ));
1549        }
1550    };
1551    if state.server_mode {
1552        let output_root = resolve_output_root(None);
1553        let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
1554        if !html_path.starts_with(&canonical_root) {
1555            return Err(locate_report_error(
1556                "Report file must be within the configured output directory.",
1557                csp_nonce,
1558            ));
1559        }
1560    }
1561    let parent = match html_path.parent() {
1562        Some(p) => p.to_path_buf(),
1563        None => {
1564            return Err(locate_report_error(
1565                "Report file has no parent directory.",
1566                csp_nonce,
1567            ));
1568        }
1569    };
1570    Ok((html_path, parent))
1571}
1572
1573/// Return a non-sensitive path hint for error messages (empty in server mode).
1574fn locate_path_hint(server_mode: bool, path: &Path) -> String {
1575    if server_mode {
1576        String::new()
1577    } else {
1578        format!("\n\nFile: {}", path.display())
1579    }
1580}
1581
1582async fn locate_report_handler(
1583    State(state): State<AppState>,
1584    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1585    Form(form): Form<LocateReportForm>,
1586) -> impl IntoResponse {
1587    let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
1588        Ok(v) => v,
1589        Err(resp) => return resp,
1590    };
1591
1592    let json_candidate = parent.join("result.json");
1593    let mut reg = state.registry.lock().await;
1594    // Find an existing entry whose output directory matches the selected file's parent.
1595    let entry_idx = reg.entries.iter().position(|e| {
1596        let json_match = e
1597            .json_path
1598            .as_ref()
1599            .and_then(|p| p.parent())
1600            .is_some_and(|p| p == parent);
1601        let html_match = e
1602            .html_path
1603            .as_ref()
1604            .and_then(|p| p.parent())
1605            .is_some_and(|p| p == parent);
1606        json_match || html_match
1607    });
1608    if let Some(idx) = entry_idx {
1609        reg.entries[idx].html_path = Some(html_path);
1610        let _ = reg.save(&state.registry_path);
1611        return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1612    }
1613    // No match — attempt to build an entry from an adjacent result.json.
1614    if json_candidate.exists() {
1615        match read_json(&json_candidate) {
1616            Ok(run) => {
1617                let entry = registry_entry_from_run(&run, json_candidate, html_path);
1618                reg.add_entry(entry);
1619                let _ = reg.save(&state.registry_path);
1620                return axum::response::Redirect::to("/view-reports?linked=1").into_response();
1621            }
1622            Err(e) => {
1623                let file_hint = locate_path_hint(state.server_mode, &json_candidate);
1624                let err_detail = if state.server_mode {
1625                    String::new()
1626                } else {
1627                    format!("\n\nError: {e}")
1628                };
1629                return locate_report_error(
1630                    format!(
1631                        "Could not link this report.\n\nA 'result.json' was found but could not \
1632                         be parsed — it may have been saved by an older version of OxideSLOC. \
1633                         Re-running the analysis will create a fresh, compatible \
1634                         record.{file_hint}{err_detail}"
1635                    ),
1636                    &csp_nonce,
1637                );
1638            }
1639        }
1640    }
1641    drop(reg);
1642    let file_hint = locate_path_hint(state.server_mode, &html_path);
1643    locate_report_error(
1644        format!(
1645            "Could not link this report.\n\nNo matching scan record was found, and no \
1646             'result.json' was found in the same folder.{file_hint}"
1647        ),
1648        &csp_nonce,
1649    )
1650}
1651
1652/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
1653fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
1654    fs::read_dir(dir)
1655        .ok()?
1656        .flatten()
1657        .map(|e| e.path())
1658        .find(|p| {
1659            p.is_file()
1660                && p.file_stem()
1661                    .and_then(|n| n.to_str())
1662                    .is_some_and(|n| n.starts_with("result"))
1663                && p.extension()
1664                    .is_some_and(|e| e.eq_ignore_ascii_case("json"))
1665        })
1666}
1667
1668#[derive(Deserialize)]
1669struct LocateReportsDirForm {
1670    folder_path: String,
1671}
1672
1673#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
1674async fn locate_reports_dir_handler(
1675    State(state): State<AppState>,
1676    Form(form): Form<LocateReportsDirForm>,
1677) -> impl IntoResponse {
1678    if state.server_mode {
1679        return StatusCode::NOT_FOUND.into_response();
1680    }
1681    let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
1682        Ok(p) => strip_unc_prefix(p),
1683        Err(_) => {
1684            return axum::response::Redirect::to(
1685                "/view-reports?error=Folder+not+found+or+path+is+invalid.",
1686            )
1687            .into_response();
1688        }
1689    };
1690    if !folder.is_dir() {
1691        return axum::response::Redirect::to(
1692            "/view-reports?error=Selected+path+is+not+a+directory.",
1693        )
1694        .into_response();
1695    }
1696
1697    let candidates = collect_result_json_candidates(&folder);
1698
1699    if candidates.is_empty() {
1700        return axum::response::Redirect::to(
1701            "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
1702        )
1703        .into_response();
1704    }
1705
1706    let mut linked_count: usize = 0;
1707    let mut reg = state.registry.lock().await;
1708    for json_path in candidates {
1709        let Some(parent) = json_path.parent().map(PathBuf::from) else {
1710            continue;
1711        };
1712        if is_dir_already_registered(&reg, &parent) {
1713            continue;
1714        }
1715        let Some(entry) = build_registry_entry_from_json(json_path) else {
1716            continue;
1717        };
1718        reg.add_entry(entry);
1719        linked_count += 1;
1720    }
1721    let _ = reg.save(&state.registry_path);
1722    drop(reg);
1723
1724    if linked_count == 0 {
1725        return axum::response::Redirect::to(
1726            "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
1727        )
1728        .into_response();
1729    }
1730    axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
1731}
1732
1733#[derive(Deserialize)]
1734struct RelocateScanForm {
1735    run_id: String,
1736    folder_path: String,
1737    redirect_url: String,
1738}
1739
1740async fn relocate_scan_handler(
1741    State(state): State<AppState>,
1742    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1743    Form(form): Form<RelocateScanForm>,
1744) -> impl IntoResponse {
1745    if state.server_mode {
1746        return StatusCode::NOT_FOUND.into_response();
1747    }
1748
1749    let run_id = form.run_id.trim().to_string();
1750    let redirect_url = form.redirect_url.trim().to_string();
1751
1752    let run_exists = {
1753        let reg = state.registry.lock().await;
1754        reg.find_by_run_id(&run_id).is_some()
1755    };
1756    if !run_exists {
1757        let html = ErrorTemplate {
1758            message: format!("Run ID '{run_id}' not found in registry."),
1759            last_report_url: Some("/compare-scans".to_string()),
1760            last_report_label: Some("Compare Scans".to_string()),
1761            csp_nonce: csp_nonce.clone(),
1762        }
1763        .render()
1764        .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1765        return Html(html).into_response();
1766    }
1767
1768    let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
1769        Ok(p) => strip_unc_prefix(p),
1770        Err(_) => {
1771            return missing_scan_relocate_response(
1772                "Folder not found or path is invalid.",
1773                &run_id,
1774                form.folder_path.trim(),
1775                &redirect_url,
1776                false,
1777                &csp_nonce,
1778            );
1779        }
1780    };
1781    if !folder.is_dir() {
1782        return missing_scan_relocate_response(
1783            "Selected path is not a directory.",
1784            &run_id,
1785            &folder.display().to_string(),
1786            &redirect_url,
1787            false,
1788            &csp_nonce,
1789        );
1790    }
1791
1792    let json_candidates = find_result_files_by_ext(&folder, "json");
1793    if json_candidates.is_empty() {
1794        return missing_scan_relocate_response(
1795            &format!(
1796                "No result JSON files found in the selected folder.\nSearched: {}",
1797                folder.display()
1798            ),
1799            &run_id,
1800            &folder.display().to_string(),
1801            &redirect_url,
1802            false,
1803            &csp_nonce,
1804        );
1805    }
1806
1807    let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
1808        return missing_scan_relocate_response(
1809            &format!(
1810                "No matching scan found in the selected folder.\n\
1811                 The JSON files present do not contain run ID: {run_id}\n\
1812                 Searched: {}",
1813                folder.display()
1814            ),
1815            &run_id,
1816            &folder.display().to_string(),
1817            &redirect_url,
1818            false,
1819            &csp_nonce,
1820        );
1821    };
1822
1823    let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
1824    let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
1825    update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
1826
1827    let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
1828        redirect_url
1829    } else {
1830        "/compare-scans".to_string()
1831    };
1832    axum::response::Redirect::to(&safe_redirect).into_response()
1833}
1834
1835fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
1836    fs::read_dir(folder)
1837        .ok()
1838        .into_iter()
1839        .flatten()
1840        .flatten()
1841        .map(|e| e.path())
1842        .filter(|p| {
1843            p.is_file()
1844                && p.file_stem()
1845                    .and_then(|n| n.to_str())
1846                    .is_some_and(|n| n.starts_with("result"))
1847                && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
1848        })
1849        .collect()
1850}
1851
1852fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
1853    candidates
1854        .iter()
1855        .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
1856        .cloned()
1857}
1858
1859async fn update_run_file_paths(
1860    state: &AppState,
1861    run_id: &str,
1862    json_path: PathBuf,
1863    html_path: Option<PathBuf>,
1864    pdf_path: Option<PathBuf>,
1865) {
1866    let mut reg = state.registry.lock().await;
1867    if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
1868        entry.json_path = Some(json_path);
1869        if let Some(hp) = html_path {
1870            entry.html_path = Some(hp);
1871        }
1872        if let Some(pp) = pdf_path {
1873            entry.pdf_path = Some(pp);
1874        }
1875    }
1876    let _ = reg.save(&state.registry_path);
1877}
1878
1879fn missing_scan_relocate_response(
1880    message: &str,
1881    run_id: &str,
1882    folder_hint: &str,
1883    redirect_url: &str,
1884    server_mode: bool,
1885    csp_nonce: &str,
1886) -> axum::response::Response {
1887    let html = RelocateScanTemplate {
1888        message: message.to_string(),
1889        run_id: run_id.to_string(),
1890        folder_hint: folder_hint.to_string(),
1891        redirect_url: redirect_url.to_string(),
1892        server_mode,
1893        csp_nonce: csp_nonce.to_owned(),
1894    }
1895    .render()
1896    .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
1897    (StatusCode::NOT_FOUND, Html(html)).into_response()
1898}
1899
1900// ── Watched-directory helpers ─────────────────────────────────────────────────
1901
1902/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
1903fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
1904    let mut candidates = Vec::new();
1905    if let Some(j) = find_result_json_in_dir(folder) {
1906        candidates.push(j);
1907    }
1908    if let Ok(dir_entries) = fs::read_dir(folder) {
1909        for entry in dir_entries.flatten() {
1910            let sub = entry.path();
1911            if sub.is_dir() {
1912                if let Some(j) = find_result_json_in_dir(&sub) {
1913                    candidates.push(j);
1914                }
1915            }
1916        }
1917    }
1918    candidates
1919}
1920
1921fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
1922    reg.entries.iter().any(|e| {
1923        let dir_match = e
1924            .json_path
1925            .as_ref()
1926            .and_then(|p| p.parent())
1927            .is_some_and(|p| p == parent)
1928            || e.html_path
1929                .as_ref()
1930                .and_then(|p| p.parent())
1931                .is_some_and(|p| p == parent);
1932        dir_match
1933            && (e.json_path.as_ref().is_some_and(|p| p.exists())
1934                || e.html_path.as_ref().is_some_and(|p| p.exists()))
1935    })
1936}
1937
1938fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
1939    let parent = json_path.parent()?.to_path_buf();
1940    let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
1941        rd.flatten()
1942            .map(|e| e.path())
1943            .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
1944    });
1945    let run = read_json(&json_path).ok()?;
1946    let project_label = run.input_roots.first().map_or_else(
1947        || "Unknown Project".to_string(),
1948        |r| sanitize_project_label(r),
1949    );
1950    Some(RegistryEntry {
1951        run_id: run.tool.run_id.clone(),
1952        timestamp_utc: run.tool.timestamp_utc,
1953        project_label,
1954        input_roots: run.input_roots.clone(),
1955        json_path: Some(json_path),
1956        html_path,
1957        pdf_path: None,
1958        csv_path: None,
1959        xlsx_path: None,
1960        summary: ScanSummarySnapshot {
1961            files_analyzed: run.summary_totals.files_analyzed,
1962            files_skipped: run.summary_totals.files_skipped,
1963            total_physical_lines: run.summary_totals.total_physical_lines,
1964            code_lines: run.summary_totals.code_lines,
1965            comment_lines: run.summary_totals.comment_lines,
1966            blank_lines: run.summary_totals.blank_lines,
1967            functions: run.summary_totals.functions,
1968            classes: run.summary_totals.classes,
1969            variables: run.summary_totals.variables,
1970            imports: run.summary_totals.imports,
1971            test_count: run.summary_totals.test_count,
1972        },
1973        git_branch: run.git_branch.clone(),
1974        git_commit: run.git_commit_short.clone(),
1975        git_author: run.git_commit_author.clone(),
1976        git_tags: run.git_tags.clone(),
1977        git_nearest_tag: run.git_nearest_tag.clone(),
1978        git_commit_date: run.git_commit_date,
1979    })
1980}
1981
1982/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
1983/// Returns the number of newly linked entries.
1984fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
1985    let mut linked = 0usize;
1986    for json_path in collect_result_json_candidates(folder) {
1987        let Some(parent) = json_path.parent().map(PathBuf::from) else {
1988            continue;
1989        };
1990        if is_dir_already_registered(reg, &parent) {
1991            continue;
1992        }
1993        let Some(entry) = build_registry_entry_from_json(json_path) else {
1994            continue;
1995        };
1996        reg.add_entry(entry);
1997        linked += 1;
1998    }
1999    linked
2000}
2001
2002/// Scan all watched directories (plus the default output root) into `reg`.
2003async fn auto_scan_watched_dirs(state: &AppState) {
2004    let dirs: Vec<PathBuf> = {
2005        let wd = state.watched_dirs.lock().await;
2006        wd.dirs.clone()
2007    };
2008    if dirs.is_empty() {
2009        return;
2010    }
2011    let mut reg = state.registry.lock().await;
2012    let mut total = 0usize;
2013    for dir in &dirs {
2014        if dir.is_dir() {
2015            total += scan_folder_into_registry(dir, &mut reg);
2016        }
2017    }
2018    if total > 0 {
2019        let _ = reg.save(&state.registry_path);
2020    }
2021}
2022
2023// ── Watched-dir route forms ───────────────────────────────────────────────────
2024
2025#[derive(Deserialize)]
2026struct WatchedDirForm {
2027    folder_path: String,
2028    #[serde(default = "default_redirect")]
2029    redirect_to: String,
2030}
2031
2032fn default_redirect() -> String {
2033    "/view-reports".to_string()
2034}
2035
2036#[derive(Deserialize)]
2037struct WatchedDirRefreshForm {
2038    #[serde(default = "default_redirect")]
2039    redirect_to: String,
2040}
2041
2042// ── Watched-dir helpers ───────────────────────────────────────────────────────
2043
2044/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
2045fn safe_redirect(dest: &str) -> &str {
2046    if dest.starts_with('/') {
2047        dest
2048    } else {
2049        "/"
2050    }
2051}
2052
2053// ── Watched-dir handlers ──────────────────────────────────────────────────────
2054
2055async fn add_watched_dir_handler(
2056    State(state): State<AppState>,
2057    Form(form): Form<WatchedDirForm>,
2058) -> impl IntoResponse {
2059    if state.server_mode {
2060        return StatusCode::NOT_FOUND.into_response();
2061    }
2062    let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
2063        strip_unc_prefix(p)
2064    } else {
2065        let dest = format!(
2066            "{}?error=Folder+not+found+or+path+is+invalid.",
2067            safe_redirect(&form.redirect_to)
2068        );
2069        return axum::response::Redirect::to(&dest).into_response();
2070    };
2071    if !folder.is_dir() {
2072        let dest = format!(
2073            "{}?error=Selected+path+is+not+a+directory.",
2074            safe_redirect(&form.redirect_to)
2075        );
2076        return axum::response::Redirect::to(&dest).into_response();
2077    }
2078
2079    // Persist the watched directory.
2080    {
2081        let mut wd = state.watched_dirs.lock().await;
2082        wd.add(folder.clone());
2083        let _ = wd.save(&state.watched_dirs_path);
2084    }
2085
2086    // Immediately scan the folder and add any new reports.
2087    let linked = {
2088        let mut reg = state.registry.lock().await;
2089        let n = scan_folder_into_registry(&folder, &mut reg);
2090        if n > 0 {
2091            let _ = reg.save(&state.registry_path);
2092        }
2093        n
2094    };
2095
2096    let dest = if linked > 0 {
2097        format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
2098    } else {
2099        format!(
2100            "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
2101            safe_redirect(&form.redirect_to)
2102        )
2103    };
2104    axum::response::Redirect::to(&dest).into_response()
2105}
2106
2107async fn remove_watched_dir_handler(
2108    State(state): State<AppState>,
2109    Form(form): Form<WatchedDirForm>,
2110) -> impl IntoResponse {
2111    if state.server_mode {
2112        return StatusCode::NOT_FOUND.into_response();
2113    }
2114    let folder = PathBuf::from(&form.folder_path);
2115    {
2116        let mut wd = state.watched_dirs.lock().await;
2117        wd.remove(&folder);
2118        let _ = wd.save(&state.watched_dirs_path);
2119    }
2120    axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
2121}
2122
2123async fn refresh_watched_dirs_handler(
2124    State(state): State<AppState>,
2125    Form(form): Form<WatchedDirRefreshForm>,
2126) -> impl IntoResponse {
2127    if state.server_mode {
2128        return StatusCode::NOT_FOUND.into_response();
2129    }
2130    let dirs: Vec<PathBuf> = {
2131        let wd = state.watched_dirs.lock().await;
2132        wd.dirs.clone()
2133    };
2134    let mut total = 0usize;
2135    {
2136        let mut reg = state.registry.lock().await;
2137        for dir in &dirs {
2138            if dir.is_dir() {
2139                total += scan_folder_into_registry(dir, &mut reg);
2140            }
2141        }
2142        if total > 0 {
2143            let _ = reg.save(&state.registry_path);
2144        }
2145    }
2146    let dest = if total > 0 {
2147        format!("{}?linked={total}", safe_redirect(&form.redirect_to))
2148    } else {
2149        safe_redirect(&form.redirect_to).to_owned()
2150    };
2151    axum::response::Redirect::to(&dest).into_response()
2152}
2153
2154#[derive(Debug, Deserialize)]
2155struct OpenPathQuery {
2156    path: Option<String>,
2157}
2158
2159async fn open_path_handler(
2160    State(state): State<AppState>,
2161    Query(query): Query<OpenPathQuery>,
2162) -> impl IntoResponse {
2163    if state.server_mode {
2164        return StatusCode::NOT_FOUND.into_response();
2165    }
2166    let raw = match query.path.as_deref() {
2167        Some(p) if !p.is_empty() => p,
2168        _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
2169    };
2170
2171    // Resolve the target directory. If the path doesn't exist yet (e.g. the output
2172    // dir hasn't been created by a scan), walk up to the nearest existing ancestor
2173    // so the file explorer still opens somewhere useful.
2174    let target = match fs::canonicalize(raw) {
2175        Ok(canonical) if canonical.is_file() => match canonical.parent() {
2176            Some(p) => p.to_path_buf(),
2177            None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
2178        },
2179        Ok(canonical) if canonical.is_dir() => canonical,
2180        Ok(_) => {
2181            return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
2182        }
2183        Err(_) => {
2184            // Path doesn't exist — find nearest existing ancestor directory.
2185            let mut ancestor = std::path::Path::new(raw);
2186            loop {
2187                match ancestor.parent() {
2188                    Some(p) => {
2189                        ancestor = p;
2190                        if ancestor.is_dir() {
2191                            break;
2192                        }
2193                    }
2194                    None => {
2195                        return (StatusCode::BAD_REQUEST, "no existing ancestor found")
2196                            .into_response();
2197                    }
2198                }
2199            }
2200            ancestor.to_path_buf()
2201        }
2202    };
2203
2204    #[cfg(target_os = "windows")]
2205    {
2206        // Open the folder in Explorer, then use SetForegroundWindow + ShowWindow(SW_MAXIMIZE=3)
2207        // to ensure the window surfaces on top of all other windows.  The path is passed via
2208        // an environment variable to avoid any command-injection or escaping issues.
2209        let ps_cmd = "Add-Type -TypeDefinition \
2210            'using System;using System.Runtime.InteropServices;\
2211            public class WF{\
2212              [DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
2213              [DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
2214            }'; \
2215            $p=$env:SLOC_OPEN_PATH; \
2216            $sh=New-Object -ComObject Shell.Application; \
2217            $sh.Open($p); \
2218            Start-Sleep -Milliseconds 600; \
2219            foreach($w in $sh.Windows()){ \
2220              try{ \
2221                if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
2222                   [System.IO.Path]::GetFullPath($p)){ \
2223                  [WF]::ShowWindow($w.HWND,3); \
2224                  [WF]::SetForegroundWindow($w.HWND); \
2225                  break \
2226                } \
2227              }catch{} \
2228            }";
2229        let _ = std::process::Command::new("powershell")
2230            .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
2231            .env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
2232            .stdout(Stdio::null())
2233            .stderr(Stdio::null())
2234            .spawn();
2235    }
2236    #[cfg(target_os = "macos")]
2237    let _ = std::process::Command::new("open")
2238        .arg(&target)
2239        .stdout(Stdio::null())
2240        .stderr(Stdio::null())
2241        .spawn();
2242    #[cfg(target_os = "linux")]
2243    let _ = std::process::Command::new("xdg-open")
2244        .arg(&target)
2245        .stdout(Stdio::null())
2246        .stderr(Stdio::null())
2247        .spawn();
2248
2249    (StatusCode::OK, "ok").into_response()
2250}
2251
2252async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
2253    let (content_type, bytes): (&'static str, &'static [u8]) =
2254        match (folder.as_str(), file.as_str()) {
2255            ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
2256            ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
2257            ("icons", "c.png") => ("image/png", IMG_ICON_C),
2258            ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
2259            ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
2260            ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
2261            ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
2262            ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
2263            ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
2264            ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
2265            ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
2266            ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
2267            ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
2268            ("icons", "go.png") => ("image/png", IMG_ICON_GO),
2269            ("icons", "r.png") => ("image/png", IMG_ICON_R),
2270            ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
2271            ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
2272            ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
2273            ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
2274            ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
2275            _ => return StatusCode::NOT_FOUND.into_response(),
2276        };
2277    ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
2278}
2279
2280async fn preview_handler(
2281    State(state): State<AppState>,
2282    Query(query): Query<PreviewQuery>,
2283) -> impl IntoResponse {
2284    let raw_path = query
2285        .path
2286        .unwrap_or_else(|| "tests/fixtures/basic".to_string());
2287    let resolved = resolve_input_path(&raw_path);
2288
2289    if state.server_mode {
2290        let config = &state.base_config;
2291        if config.discovery.allowed_scan_roots.is_empty() {
2292            return Html(
2293                r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
2294            );
2295        }
2296        let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
2297        let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2298            fs::canonicalize(root)
2299                .ok()
2300                .is_some_and(|r| canonical.starts_with(&r))
2301        });
2302        if !allowed {
2303            return Html(
2304                r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
2305            );
2306        }
2307    }
2308
2309    let include_patterns = split_patterns(query.include_globs.as_deref());
2310    let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
2311
2312    match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
2313        Ok(html) => Html(html),
2314        Err(err) => Html(format!(
2315            r#"<div class="preview-error">Preview failed: {}</div>"#,
2316            escape_html(&err.to_string())
2317        )),
2318    }
2319}
2320
2321#[derive(Debug, Deserialize, Default)]
2322struct SuggestCoverageQuery {
2323    path: Option<String>,
2324}
2325
2326#[derive(Serialize)]
2327struct SuggestCoverageResponse {
2328    found: Option<String>,
2329    tool: Option<&'static str>,
2330    hint: Option<&'static str>,
2331}
2332
2333async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
2334    const CANDIDATES: &[&str] = &[
2335        // LCOV — cargo-llvm-cov, gcov, lcov
2336        "coverage/lcov.info",
2337        "lcov.info",
2338        "target/llvm-cov/lcov.info",
2339        "target/coverage/lcov.info",
2340        "target/debug/coverage/lcov.info",
2341        "coverage/coverage.lcov",
2342        "build/coverage/lcov.info",
2343        "reports/lcov.info",
2344        // Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
2345        "coverage.xml",
2346        "coverage/coverage.xml",
2347        "target/site/cobertura/coverage.xml",
2348        "build/reports/coverage/coverage.xml",
2349        // JaCoCo XML — Gradle, Maven JaCoCo plugin
2350        "target/site/jacoco/jacoco.xml",
2351        "build/reports/jacoco/test/jacocoTestReport.xml",
2352        "build/reports/jacoco/jacocoTestReport.xml",
2353        "build/jacoco/jacoco.xml",
2354    ];
2355    let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
2356    let found = CANDIDATES
2357        .iter()
2358        .map(|rel| root.join(rel))
2359        .find(|p| p.is_file())
2360        .map(|p| display_path(&p));
2361
2362    let (tool, hint) = detect_coverage_tool(&root);
2363    Json(SuggestCoverageResponse { found, tool, hint })
2364}
2365
2366/// Inspect the project root for known build/package files and return the most likely coverage
2367/// tool name and the shell command needed to generate a coverage file.
2368fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
2369    if root.join("Cargo.toml").is_file() {
2370        return (
2371            Some("cargo-llvm-cov"),
2372            Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
2373        );
2374    }
2375    if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
2376        return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
2377    }
2378    if root.join("pom.xml").is_file() {
2379        return (Some("jacoco"), Some("mvn test jacoco:report"));
2380    }
2381    if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
2382        return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
2383    }
2384    (None, None)
2385}
2386
2387/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
2388#[allow(clippy::result_large_err)]
2389fn validate_server_scan_path(
2390    config: &sloc_config::AppConfig,
2391    resolved_path: &Path,
2392    csp_nonce: &str,
2393) -> Result<(), Response> {
2394    if config.discovery.allowed_scan_roots.is_empty() {
2395        let template = ErrorTemplate {
2396            message: "Scan path rejected: no allowed_scan_roots configured on this server. \
2397                      Set allowed_scan_roots in the server config to permit scanning."
2398                .to_string(),
2399            last_report_url: None,
2400            last_report_label: None,
2401            csp_nonce: csp_nonce.to_owned(),
2402        };
2403        return Err((
2404            StatusCode::FORBIDDEN,
2405            Html(
2406                template
2407                    .render()
2408                    .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
2409            ),
2410        )
2411            .into_response());
2412    }
2413    let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
2414    let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
2415        fs::canonicalize(root)
2416            .ok()
2417            .is_some_and(|r| canonical.starts_with(&r))
2418    });
2419    if !allowed {
2420        tracing::warn!(event = "path_rejected", path = %canonical.display(),
2421            "Scan path not in allowed_scan_roots");
2422        let template = ErrorTemplate {
2423            message: "The requested path is not within an allowed scan directory.".to_string(),
2424            last_report_url: None,
2425            last_report_label: None,
2426            csp_nonce: csp_nonce.to_owned(),
2427        };
2428        return Err((
2429            StatusCode::FORBIDDEN,
2430            Html(
2431                template
2432                    .render()
2433                    .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
2434            ),
2435        )
2436            .into_response());
2437    }
2438    Ok(())
2439}
2440
2441/// Exclude the output directory from scanning so artifacts don't pollute counts.
2442fn apply_output_dir_exclusions(
2443    config: &mut sloc_config::AppConfig,
2444    project_path: &str,
2445    raw_output_dir: &str,
2446) {
2447    let project_root = resolve_input_path(project_path);
2448    let raw_out = raw_output_dir.trim();
2449    let resolved_out = if raw_out.is_empty() {
2450        project_root.join("sloc")
2451    } else if Path::new(raw_out).is_absolute() {
2452        PathBuf::from(raw_out)
2453    } else {
2454        workspace_root().join(raw_out)
2455    };
2456    if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
2457        if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
2458            let dir = first.to_string();
2459            if !config.discovery.excluded_directories.contains(&dir) {
2460                config.discovery.excluded_directories.push(dir);
2461            }
2462        }
2463    }
2464    if !config
2465        .discovery
2466        .excluded_directories
2467        .iter()
2468        .any(|d| d == "sloc")
2469    {
2470        config
2471            .discovery
2472            .excluded_directories
2473            .push("sloc".to_string());
2474    }
2475}
2476
2477/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
2478const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
2479    ScanSummarySnapshot {
2480        files_analyzed: run.summary_totals.files_analyzed,
2481        files_skipped: run.summary_totals.files_skipped,
2482        total_physical_lines: run.summary_totals.total_physical_lines,
2483        code_lines: run.summary_totals.code_lines,
2484        comment_lines: run.summary_totals.comment_lines,
2485        blank_lines: run.summary_totals.blank_lines,
2486        functions: run.summary_totals.functions,
2487        classes: run.summary_totals.classes,
2488        variables: run.summary_totals.variables,
2489        imports: run.summary_totals.imports,
2490        test_count: run.summary_totals.test_count,
2491    }
2492}
2493
2494/// Build the `RegistryEntry` for the just-completed scan run.
2495pub(crate) fn build_run_registry_entry(
2496    run: &AnalysisRun,
2497    run_id: &str,
2498    project_label: &str,
2499    artifacts: &RunArtifacts,
2500) -> RegistryEntry {
2501    RegistryEntry {
2502        run_id: run_id.to_owned(),
2503        timestamp_utc: run.tool.timestamp_utc,
2504        project_label: project_label.to_owned(),
2505        input_roots: run.input_roots.clone(),
2506        json_path: artifacts.json_path.clone(),
2507        html_path: artifacts.html_path.clone(),
2508        pdf_path: artifacts.pdf_path.clone(),
2509        csv_path: artifacts.csv_path.clone(),
2510        xlsx_path: artifacts.xlsx_path.clone(),
2511        summary: summary_snapshot_from_run(run),
2512        git_branch: run.git_branch.clone(),
2513        git_commit: run.git_commit_short.clone(),
2514        git_author: run.git_commit_author.clone(),
2515        git_tags: run.git_tags.clone(),
2516        git_nearest_tag: run.git_nearest_tag.clone(),
2517        git_commit_date: run.git_commit_date.clone(),
2518    }
2519}
2520
2521/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
2522fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
2523    if let Some(policy) = form.mixed_line_policy {
2524        config.analysis.mixed_line_policy = policy;
2525    }
2526    config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
2527    config.analysis.generated_file_detection =
2528        form.generated_file_detection.as_deref() != Some("disabled");
2529    config.analysis.minified_file_detection =
2530        form.minified_file_detection.as_deref() != Some("disabled");
2531    config.analysis.vendor_directory_detection =
2532        form.vendor_directory_detection.as_deref() != Some("disabled");
2533    config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
2534    if let Some(binary_behavior) = form.binary_file_behavior {
2535        config.analysis.binary_file_behavior = binary_behavior;
2536    }
2537    if let Some(report_title) = form.report_title.as_deref() {
2538        let trimmed = report_title.trim();
2539        if !trimmed.is_empty() {
2540            config.reporting.report_title = trimmed.to_string();
2541        }
2542    }
2543    if let Some(hf) = form.report_header_footer.as_deref() {
2544        let trimmed = hf.trim();
2545        config.reporting.report_header_footer = if trimmed.is_empty() {
2546            None
2547        } else {
2548            Some(trimmed.to_string())
2549        };
2550    }
2551    config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
2552    config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
2553    config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
2554    if let Some(cov) = &form.coverage_file {
2555        let trimmed = cov.trim();
2556        if !trimmed.is_empty() {
2557            config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
2558        }
2559    }
2560}
2561
2562/// Fire-and-forget: generate the PDF in a background task if one is pending.
2563/// On failure, clears `pdf_path` in the artifacts map so the results page shows
2564/// an error instead of spinning indefinitely.
2565fn spawn_pdf_background(
2566    pending_pdf: PendingPdf,
2567    run_id: String,
2568    artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
2569) {
2570    if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
2571        tokio::spawn(async move {
2572            let result = tokio::task::spawn_blocking(move || {
2573                let r = write_pdf_from_html(&pdf_src, &pdf_dst);
2574                if cleanup_src {
2575                    let _ = fs::remove_file(&pdf_src);
2576                }
2577                r
2578            })
2579            .await;
2580            let failed = match result {
2581                Ok(Ok(())) => false,
2582                Ok(Err(err)) => {
2583                    eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
2584                    true
2585                }
2586                Err(err) => {
2587                    eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
2588                    true
2589                }
2590            };
2591            if failed {
2592                let mut map = artifacts.lock().await;
2593                if let Some(entry) = map.get_mut(&run_id) {
2594                    entry.pdf_path = None;
2595                }
2596            }
2597        });
2598    }
2599}
2600
2601/// Sum the code lines added in this comparison (new + grown files).
2602fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2603    cmp.file_deltas
2604        .iter()
2605        .map(|f| match f.status {
2606            FileChangeStatus::Added => f.current_code,
2607            FileChangeStatus::Modified => f.code_delta.max(0),
2608            _ => 0,
2609        })
2610        .sum()
2611}
2612
2613/// Sum the code lines removed in this comparison (deleted + shrunk files).
2614fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
2615    cmp.file_deltas
2616        .iter()
2617        .map(|f| match f.status {
2618            FileChangeStatus::Removed => f.baseline_code,
2619            FileChangeStatus::Modified => (-f.code_delta).max(0),
2620            _ => 0,
2621        })
2622        .sum()
2623}
2624
2625/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
2626fn build_submodule_row(
2627    s: &sloc_core::SubmoduleSummary,
2628    run: &AnalysisRun,
2629    run_id: &str,
2630    run_dir: &Path,
2631    generate_html: bool,
2632) -> SubmoduleRow {
2633    let safe = sanitize_project_label(&s.name);
2634    let artifact_key = format!("sub_{safe}");
2635    let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
2636        let parent_path = run
2637            .input_roots
2638            .first()
2639            .map_or("", std::string::String::as_str);
2640        let sub_run = build_sub_run(run, s, parent_path);
2641        render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
2642            let path = run_dir.join(format!("{artifact_key}.html"));
2643            if fs::write(&path, sub_html.as_bytes()).is_ok() {
2644                Some(format!("/runs/{artifact_key}/{run_id}"))
2645            } else {
2646                None
2647            }
2648        })
2649    } else {
2650        None
2651    };
2652    SubmoduleRow {
2653        name: s.name.clone(),
2654        relative_path: s.relative_path.clone(),
2655        files_analyzed: s.files_analyzed,
2656        code_lines: s.code_lines,
2657        comment_lines: s.comment_lines,
2658        blank_lines: s.blank_lines,
2659        total_physical_lines: s.total_physical_lines,
2660        html_url,
2661    }
2662}
2663
2664// Immediately returns a wait page and runs the analysis in a background tokio task.
2665// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
2666#[allow(clippy::similar_names)]
2667#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
2668async fn analyze_handler(
2669    State(state): State<AppState>,
2670    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2671    Form(form): Form<AnalyzeForm>,
2672) -> impl IntoResponse {
2673    let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
2674        let template = ErrorTemplate {
2675            message: "Server is busy — too many concurrent analyses. Please try again in a moment."
2676                .to_string(),
2677            last_report_url: None,
2678            last_report_label: None,
2679            csp_nonce: csp_nonce.clone(),
2680        };
2681        return (
2682            StatusCode::SERVICE_UNAVAILABLE,
2683            Html(
2684                template
2685                    .render()
2686                    .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
2687            ),
2688        )
2689            .into_response();
2690    };
2691
2692    let mut config = state.base_config.clone();
2693
2694    let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
2695    let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
2696    let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
2697
2698    if !is_git_mode {
2699        let resolved_path = resolve_input_path(&form.path);
2700        if state.server_mode {
2701            if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
2702                return resp;
2703            }
2704        }
2705        config.discovery.root_paths = vec![resolved_path];
2706    }
2707
2708    apply_form_to_config(&mut config, &form);
2709    apply_output_dir_exclusions(
2710        &mut config,
2711        &form.path,
2712        form.output_dir.as_deref().unwrap_or(""),
2713    );
2714
2715    // Generate a wait_id now (before spawning) so the client can poll for status.
2716    let wait_id = uuid::Uuid::new_v4().to_string();
2717    let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
2718
2719    // Cancel token: set to true by the cancel endpoint to abort the running analysis.
2720    let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
2721    let task_cancel = Arc::clone(&cancel_token);
2722
2723    // Register Running state before building the task struct so the semaphore permit
2724    // (which has a significant Drop) isn't held across the async_runs lock acquisition.
2725    {
2726        let mut runs = state.async_runs.lock().await;
2727        runs.insert(
2728            wait_id.clone(),
2729            AsyncRunState::Running {
2730                started_at: std::time::Instant::now(),
2731                cancel_token,
2732            },
2733        );
2734    }
2735
2736    let task = AnalysisTask {
2737        sem_permit,
2738        state: state.clone(),
2739        wait_id: wait_id.clone(),
2740        config,
2741        cancel: task_cancel,
2742        git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
2743        git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
2744        generate_html: form.generate_html.is_some(),
2745        generate_pdf: form.generate_pdf.is_some(),
2746        project_path: form.path.clone(),
2747        output_dir: form.output_dir.clone(),
2748        clones_dir: state.git_clones_dir.clone(),
2749    };
2750
2751    tokio::spawn(run_analysis_task(task));
2752
2753    let template = ScanWaitTemplate {
2754        version: env!("CARGO_PKG_VERSION"),
2755        wait_id_json,
2756        project_path: form.path.clone(),
2757        csp_nonce,
2758    };
2759    let html = template
2760        .render()
2761        .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
2762    let mut response = Html(html).into_response();
2763    if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
2764        if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
2765            response.headers_mut().insert(name, val);
2766        }
2767    }
2768    response
2769}
2770
2771struct AnalysisTask {
2772    sem_permit: tokio::sync::OwnedSemaphorePermit,
2773    state: AppState,
2774    wait_id: String,
2775    config: AppConfig,
2776    cancel: Arc<std::sync::atomic::AtomicBool>,
2777    git_repo: Option<String>,
2778    git_ref: Option<String>,
2779    generate_html: bool,
2780    generate_pdf: bool,
2781    project_path: String,
2782    output_dir: Option<String>,
2783    clones_dir: PathBuf,
2784}
2785
2786#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
2787async fn run_analysis_task(task: AnalysisTask) {
2788    let _permit = task.sem_permit;
2789
2790    let cancel_sb = Arc::clone(&task.cancel);
2791    let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
2792    let clones_dir_sb = task.clones_dir;
2793    let config_sb = task.config;
2794    let analysis_result = tokio::task::spawn_blocking(move || {
2795        run_analysis_blocking(config_sb, git_repo_sb, git_ref_sb, clones_dir_sb, cancel_sb)
2796    })
2797    .await
2798    .map_err(|err| anyhow::anyhow!(err.to_string()))
2799    .and_then(|result| result);
2800
2801    // If cancelled while running, discard results and mark as cancelled.
2802    if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
2803        let mut runs = task.state.async_runs.lock().await;
2804        // Only overwrite if still Running (don't clobber a Complete that snuck in).
2805        if matches!(
2806            runs.get(&task.wait_id),
2807            Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
2808        ) {
2809            runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
2810        }
2811        drop(runs);
2812        return;
2813    }
2814
2815    let (run, report_html) = match analysis_result {
2816        Ok(v) => v,
2817        Err(err) => {
2818            // Distinguish user-cancelled from real failure.
2819            if err.to_string().contains("analysis cancelled") {
2820                let mut runs = task.state.async_runs.lock().await;
2821                runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
2822                drop(runs);
2823                return;
2824            }
2825            eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
2826            let mut runs = task.state.async_runs.lock().await;
2827            runs.insert(
2828                task.wait_id.clone(),
2829                AsyncRunState::Failed {
2830                    message: "Analysis failed. Check that the path exists and is readable."
2831                        .to_string(),
2832                },
2833            );
2834            drop(runs);
2835            return;
2836        }
2837    };
2838
2839    let run_id = run.tool.run_id.clone();
2840    tracing::info!(event = "scan_complete", run_id = %run_id,
2841        path = %task.project_path, files = run.summary_totals.files_analyzed,
2842        "Analysis finished");
2843
2844    let prev_entry: Option<RegistryEntry> = {
2845        let reg = task.state.registry.lock().await;
2846        reg.entries_for_roots(&run.input_roots)
2847            .into_iter()
2848            .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2849            .cloned()
2850    };
2851
2852    let scan_delta = prev_entry.as_ref().and_then(|prev| {
2853        prev.json_path
2854            .as_ref()
2855            .and_then(|p| read_json(p).ok())
2856            .map(|prev_run| compute_delta(&prev_run, &run))
2857    });
2858    let prev_scan_count: usize = {
2859        let reg = task.state.registry.lock().await;
2860        reg.entries_for_roots(&run.input_roots)
2861            .iter()
2862            .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
2863            .count()
2864    };
2865
2866    let output_root = resolve_output_root(task.output_dir.as_deref());
2867    let project_label = derive_project_label(
2868        task.git_repo.as_deref(),
2869        task.git_ref.as_deref(),
2870        &task.project_path,
2871    );
2872    let run_dir = output_root.join(format!("{project_label}_{run_id}"));
2873    let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
2874
2875    let result_context = RunResultContext {
2876        prev_entry: prev_entry.clone(),
2877        prev_scan_count,
2878        project_path: task.project_path.clone(),
2879    };
2880
2881    let artifact_result = persist_run_artifacts(
2882        &run,
2883        &report_html,
2884        &run_dir,
2885        true,
2886        task.generate_html,
2887        task.generate_pdf,
2888        &run.effective_configuration.reporting.report_title,
2889        &file_stem,
2890        result_context,
2891    );
2892
2893    let (artifacts, pending_pdf) = match artifact_result {
2894        Ok(v) => v,
2895        Err(err) => {
2896            eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
2897            let mut runs = task.state.async_runs.lock().await;
2898            runs.insert(
2899                task.wait_id.clone(),
2900                AsyncRunState::Failed {
2901                    message: "Failed to save report artifacts. Check available disk space."
2902                        .to_string(),
2903                },
2904            );
2905            drop(runs);
2906            return;
2907        }
2908    };
2909
2910    {
2911        let mut map = task.state.artifacts.lock().await;
2912        map.insert(run_id.clone(), artifacts.clone());
2913    }
2914
2915    {
2916        let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
2917        let mut reg = task.state.registry.lock().await;
2918        reg.add_entry(entry);
2919        let _ = reg.save(&task.state.registry_path);
2920    }
2921
2922    if let Some(ref cfg_path) = artifacts.scan_config_path {
2923        save_scan_config_json(
2924            cfg_path,
2925            &run,
2926            &task.project_path,
2927            task.output_dir.as_deref(),
2928            task.generate_html,
2929            task.generate_pdf,
2930        );
2931    }
2932
2933    spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
2934
2935    // Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
2936    let mut runs = task.state.async_runs.lock().await;
2937    runs.insert(
2938        task.wait_id.clone(),
2939        AsyncRunState::Complete {
2940            run_id: run_id.clone(),
2941        },
2942    );
2943    drop(runs);
2944
2945    let _ = scan_delta;
2946}
2947
2948fn save_scan_config_json(
2949    cfg_path: &std::path::Path,
2950    run: &sloc_core::AnalysisRun,
2951    project_path: &str,
2952    output_dir: Option<&str>,
2953    generate_html: bool,
2954    generate_pdf: bool,
2955) {
2956    let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
2957        .ok()
2958        .and_then(|v| v.as_str().map(String::from))
2959        .unwrap_or_else(|| "code_only".to_string());
2960    let behavior_str =
2961        serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
2962            .ok()
2963            .and_then(|v| v.as_str().map(String::from))
2964            .unwrap_or_else(|| "skip".to_string());
2965    let scan_cfg = ScanConfig {
2966        oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2967        path: project_path.to_string(),
2968        include_globs: run
2969            .effective_configuration
2970            .discovery
2971            .include_globs
2972            .join("\n"),
2973        exclude_globs: run
2974            .effective_configuration
2975            .discovery
2976            .exclude_globs
2977            .join("\n"),
2978        submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
2979        mixed_line_policy: policy_str,
2980        python_docstrings_as_comments: run
2981            .effective_configuration
2982            .analysis
2983            .python_docstrings_as_comments,
2984        generated_file_detection: run
2985            .effective_configuration
2986            .analysis
2987            .generated_file_detection,
2988        minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
2989        vendor_directory_detection: run
2990            .effective_configuration
2991            .analysis
2992            .vendor_directory_detection,
2993        include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
2994        binary_file_behavior: behavior_str,
2995        output_dir: output_dir.unwrap_or("").to_string(),
2996        report_title: run.effective_configuration.reporting.report_title.clone(),
2997        generate_html,
2998        generate_pdf,
2999    };
3000    if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
3001        let _ = std::fs::write(cfg_path, json);
3002    }
3003}
3004
3005#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
3006fn run_analysis_blocking(
3007    mut config: AppConfig,
3008    git_repo: Option<String>,
3009    git_ref: Option<String>,
3010    clones_dir: PathBuf,
3011    cancel: Arc<std::sync::atomic::AtomicBool>,
3012) -> Result<(sloc_core::AnalysisRun, String)> {
3013    if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
3014        let dest = git_clone_dest(&repo, &clones_dir);
3015        sloc_git::clone_or_fetch(&repo, &dest)?;
3016        let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
3017        sloc_git::create_worktree(&dest, &refname, &wt)?;
3018        config.discovery.root_paths = vec![wt.clone()];
3019        let run = analyze(&config, "serve", Some(&cancel));
3020        let _ = sloc_git::destroy_worktree(&dest, &wt);
3021        let mut run = run?;
3022        if run.git_branch.is_none() {
3023            run.git_branch = Some(refname);
3024        }
3025        let html = render_html(&run)?;
3026        return Ok((run, html));
3027    }
3028    let run = analyze(&config, "serve", Some(&cancel))?;
3029    let html = render_html(&run)?;
3030    Ok((run, html))
3031}
3032
3033fn derive_project_label(
3034    git_repo: Option<&str>,
3035    git_ref: Option<&str>,
3036    fallback_path: &str,
3037) -> String {
3038    match (
3039        git_repo.filter(|s| !s.is_empty()),
3040        git_ref.filter(|s| !s.is_empty()),
3041    ) {
3042        (Some(repo), Some(refname)) => {
3043            let repo_name = repo
3044                .trim_end_matches('/')
3045                .trim_end_matches(".git")
3046                .rsplit('/')
3047                .next()
3048                .unwrap_or("repo");
3049            sanitize_project_label(&format!("{repo_name}_{refname}"))
3050        }
3051        _ => sanitize_project_label(fallback_path),
3052    }
3053}
3054
3055fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
3056    let commit = commit_short.unwrap_or("").trim();
3057    if commit.is_empty() {
3058        project_label.to_string()
3059    } else {
3060        format!("{project_label}_{commit}")
3061    }
3062}
3063
3064// ── Async scan status + result handlers ──────────────────────────────────────
3065
3066#[derive(Serialize)]
3067#[serde(tag = "state", rename_all = "snake_case")]
3068enum AsyncRunStatusResponse {
3069    Running { elapsed_secs: u64 },
3070    Complete { run_id: String },
3071    Failed { message: String },
3072    Cancelled,
3073}
3074
3075async fn async_run_status_handler(
3076    State(state): State<AppState>,
3077    AxumPath(wait_id): AxumPath<String>,
3078) -> Response {
3079    // wait_id comes from our own UUID generator; reject any structurally malformed value.
3080    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3081        return error::bad_request("invalid wait_id");
3082    }
3083    let run_state = {
3084        let runs = state.async_runs.lock().await;
3085        runs.get(&wait_id).cloned()
3086    };
3087    match run_state {
3088        None => error::not_found("run not found"),
3089        Some(AsyncRunState::Running { started_at, .. }) => {
3090            // Treat runs older than 2 h as timed out (analysis should finish well under that).
3091            if started_at.elapsed() > std::time::Duration::from_hours(2) {
3092                let mut runs = state.async_runs.lock().await;
3093                runs.insert(
3094                    wait_id,
3095                    AsyncRunState::Failed {
3096                        message: "Analysis timed out after 2 hours.".to_string(),
3097                    },
3098                );
3099                drop(runs);
3100                return Json(AsyncRunStatusResponse::Failed {
3101                    message: "Analysis timed out after 2 hours.".to_string(),
3102                })
3103                .into_response();
3104            }
3105            Json(AsyncRunStatusResponse::Running {
3106                elapsed_secs: started_at.elapsed().as_secs(),
3107            })
3108            .into_response()
3109        }
3110        Some(AsyncRunState::Complete { run_id }) => {
3111            Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
3112        }
3113        Some(AsyncRunState::Failed { message }) => {
3114            Json(AsyncRunStatusResponse::Failed { message }).into_response()
3115        }
3116        Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
3117    }
3118}
3119
3120async fn cancel_run_handler(
3121    State(state): State<AppState>,
3122    AxumPath(wait_id): AxumPath<String>,
3123) -> Response {
3124    if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
3125        return error::bad_request("invalid wait_id");
3126    }
3127    let mut runs = state.async_runs.lock().await;
3128    let resp = match runs.get(&wait_id) {
3129        Some(AsyncRunState::Running { cancel_token, .. }) => {
3130            cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
3131            runs.insert(wait_id, AsyncRunState::Cancelled);
3132            StatusCode::OK.into_response()
3133        }
3134        Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
3135        _ => error::not_found("run not found"),
3136    };
3137    drop(runs);
3138    resp
3139}
3140
3141async fn async_run_result_handler(
3142    State(state): State<AppState>,
3143    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3144    AxumPath(run_id): AxumPath<String>,
3145) -> Response {
3146    if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
3147        return StatusCode::BAD_REQUEST.into_response();
3148    }
3149
3150    let artifacts = {
3151        let map = state.artifacts.lock().await;
3152        map.get(&run_id).cloned()
3153    };
3154    let artifacts = if let Some(a) = artifacts {
3155        a
3156    } else {
3157        let reg = state.registry.lock().await;
3158        if let Some(entry) = reg.find_by_run_id(&run_id) {
3159            recover_artifacts_from_registry(entry)
3160        } else {
3161            let html = ErrorTemplate {
3162                message: format!(
3163                    "Report not found. Run ID {} is not in the scan history.",
3164                    &run_id[..run_id.len().min(8)]
3165                ),
3166                last_report_url: Some("/view-reports".to_string()),
3167                last_report_label: Some("View Reports".to_string()),
3168                csp_nonce: csp_nonce.clone(),
3169            }
3170            .render()
3171            .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3172            return (StatusCode::NOT_FOUND, Html(html)).into_response();
3173        }
3174    };
3175
3176    let json_path = if let Some(p) = &artifacts.json_path {
3177        p.clone()
3178    } else {
3179        let html = ErrorTemplate {
3180            message: "JSON result was not saved for this run.".to_string(),
3181            last_report_url: Some("/view-reports".to_string()),
3182            last_report_label: Some("View Reports".to_string()),
3183            csp_nonce: csp_nonce.clone(),
3184        }
3185        .render()
3186        .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
3187        return (StatusCode::NOT_FOUND, Html(html)).into_response();
3188    };
3189
3190    let Ok(run) = read_json(&json_path) else {
3191        let folder_hint = json_path
3192            .parent()
3193            .map(|p| p.display().to_string())
3194            .unwrap_or_default();
3195        let redirect_url = format!("/runs/result/{run_id}");
3196        return missing_scan_relocate_response(
3197            &format!(
3198                "Scan file could not be read:\n  {}\n\nThe file may have been moved or \
3199                 deleted. Browse to the folder containing your scan output to reconnect it.",
3200                json_path.display()
3201            ),
3202            &run_id,
3203            &folder_hint,
3204            &redirect_url,
3205            state.server_mode,
3206            &csp_nonce,
3207        );
3208    };
3209
3210    let confluence_configured = {
3211        let store = state.confluence.lock().await;
3212        store.is_configured()
3213    };
3214
3215    render_result_page(&run, &artifacts, &run_id, &csp_nonce, confluence_configured)
3216}
3217
3218#[allow(clippy::too_many_lines)]
3219#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
3220fn render_result_page(
3221    run: &AnalysisRun,
3222    artifacts: &RunArtifacts,
3223    run_id: &str,
3224    csp_nonce: &str,
3225    confluence_configured: bool,
3226) -> Response {
3227    let ctx = &artifacts.result_context;
3228    let prev_entry = &ctx.prev_entry;
3229    let prev_scan_count = ctx.prev_scan_count;
3230    let project_path = &ctx.project_path;
3231
3232    let scan_delta = prev_entry.as_ref().and_then(|prev| {
3233        prev.json_path
3234            .as_ref()
3235            .and_then(|p| read_json(p).ok())
3236            .map(|prev_run| compute_delta(&prev_run, run))
3237    });
3238
3239    let files_analyzed = run.per_file_records.len() as u64;
3240    let files_skipped = run.skipped_file_records.len() as u64;
3241    let physical_lines = run
3242        .totals_by_language
3243        .iter()
3244        .map(|r| r.total_physical_lines)
3245        .sum::<u64>();
3246    let code_lines = run
3247        .totals_by_language
3248        .iter()
3249        .map(|r| r.code_lines)
3250        .sum::<u64>();
3251    let comment_lines = run
3252        .totals_by_language
3253        .iter()
3254        .map(|r| r.comment_lines)
3255        .sum::<u64>();
3256    let blank_lines = run
3257        .totals_by_language
3258        .iter()
3259        .map(|r| r.blank_lines)
3260        .sum::<u64>();
3261    let mixed_lines = run
3262        .totals_by_language
3263        .iter()
3264        .map(|r| r.mixed_lines_separate)
3265        .sum::<u64>();
3266    let functions = run
3267        .totals_by_language
3268        .iter()
3269        .map(|r| r.functions)
3270        .sum::<u64>();
3271    let classes = run
3272        .totals_by_language
3273        .iter()
3274        .map(|r| r.classes)
3275        .sum::<u64>();
3276    let variables = run
3277        .totals_by_language
3278        .iter()
3279        .map(|r| r.variables)
3280        .sum::<u64>();
3281    let imports = run
3282        .totals_by_language
3283        .iter()
3284        .map(|r| r.imports)
3285        .sum::<u64>();
3286
3287    let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
3288    let prev_fa = prev_sum.map(|s| s.files_analyzed);
3289    let prev_fs = prev_sum.map(|s| s.files_skipped);
3290    let prev_pl = prev_sum.map(|s| s.total_physical_lines);
3291    let prev_cl = prev_sum.map(|s| s.code_lines);
3292    let prev_cml = prev_sum.map(|s| s.comment_lines);
3293    let prev_bl = prev_sum.map(|s| s.blank_lines);
3294    let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
3295    let prev_fa_str = fmt_prev(prev_fa);
3296    let prev_fs_str = fmt_prev(prev_fs);
3297    let prev_pl_str = fmt_prev(prev_pl);
3298    let prev_cl_str = fmt_prev(prev_cl);
3299    let prev_cml_str = fmt_prev(prev_cml);
3300    let prev_bl_str = fmt_prev(prev_bl);
3301    let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
3302    let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
3303    let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
3304    let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
3305    let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
3306    let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
3307    let delta_fa_class = delta_fa_class.to_string();
3308    let delta_fs_class = delta_fs_class.to_string();
3309    let delta_pl_class = delta_pl_class.to_string();
3310    let delta_cl_class = delta_cl_class.to_string();
3311    let delta_cml_class = delta_cml_class.to_string();
3312    let delta_bl_class = delta_bl_class.to_string();
3313
3314    let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
3315    let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
3316    let (delta_lines_net_str, delta_lines_net_class) =
3317        match (delta_lines_added, delta_lines_removed) {
3318            (Some(a), Some(r)) => {
3319                let net = a - r;
3320                (fmt_delta(net), delta_class(net).to_string())
3321            }
3322            _ => ("—".to_string(), "na".to_string()),
3323        };
3324
3325    let run_dir = artifacts.output_dir.clone();
3326    let git_branch = run.git_branch.clone();
3327    let git_commit = run.git_commit_short.clone();
3328    let git_author = run.git_commit_author.clone();
3329
3330    let template = ResultTemplate {
3331        version: env!("CARGO_PKG_VERSION"),
3332        report_title: run.effective_configuration.reporting.report_title.clone(),
3333        project_path: project_path.clone(),
3334        output_dir: display_path(&artifacts.output_dir),
3335        run_id: run_id.to_owned(),
3336        files_analyzed,
3337        files_skipped,
3338        physical_lines,
3339        code_lines,
3340        comment_lines,
3341        blank_lines,
3342        mixed_lines,
3343        functions,
3344        classes,
3345        variables,
3346        imports,
3347        html_url: artifacts
3348            .html_path
3349            .as_ref()
3350            .map(|_| format!("/runs/html/{run_id}")),
3351        pdf_url: artifacts
3352            .pdf_path
3353            .as_ref()
3354            .map(|_| format!("/runs/pdf/{run_id}")),
3355        json_url: artifacts
3356            .json_path
3357            .as_ref()
3358            .map(|_| format!("/runs/json/{run_id}")),
3359        html_download_url: artifacts
3360            .html_path
3361            .as_ref()
3362            .map(|_| format!("/runs/html/{run_id}?download=1")),
3363        pdf_download_url: artifacts
3364            .pdf_path
3365            .as_ref()
3366            .map(|_| format!("/runs/pdf/{run_id}?download=1")),
3367        json_download_url: artifacts
3368            .json_path
3369            .as_ref()
3370            .map(|_| format!("/runs/json/{run_id}?download=1")),
3371        html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
3372        pdf_path: artifacts.pdf_path.as_ref().map(|p| display_path(p)),
3373        json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
3374        prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
3375        prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
3376        prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
3377        prev_fa_str,
3378        prev_fs_str,
3379        prev_pl_str,
3380        prev_cl_str,
3381        prev_cml_str,
3382        prev_bl_str,
3383        delta_fa_str,
3384        delta_fa_class,
3385        delta_fs_str,
3386        delta_fs_class,
3387        delta_pl_str,
3388        delta_pl_class,
3389        delta_cl_str,
3390        delta_cl_class,
3391        delta_cml_str,
3392        delta_cml_class,
3393        delta_bl_str,
3394        delta_bl_class,
3395        delta_lines_added,
3396        delta_lines_removed,
3397        delta_lines_net_str,
3398        delta_lines_net_class,
3399        delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
3400        delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
3401        delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
3402        delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
3403        delta_unmodified_lines: scan_delta.as_ref().map(|d| {
3404            d.file_deltas
3405                .iter()
3406                .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
3407                .map(|f| {
3408                    #[allow(clippy::cast_sign_loss)]
3409                    let n = f.current_code as u64;
3410                    n
3411                })
3412                .sum()
3413        }),
3414        git_branch,
3415        git_commit,
3416        git_author,
3417        current_scan_number: prev_scan_count + 1,
3418        prev_scan_count,
3419        submodule_rows: run
3420            .submodule_summaries
3421            .iter()
3422            .map(|s| build_submodule_row(s, run, run_id, &run_dir, artifacts.html_path.is_some()))
3423            .collect(),
3424        pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
3425        scan_config_url: format!("/runs/scan-config/{run_id}"),
3426        lang_chart_json: {
3427            let entries: Vec<String> = run
3428                .totals_by_language
3429                .iter()
3430                .take(12)
3431                .map(|l| {
3432                    let name = l
3433                        .language
3434                        .display_name()
3435                        .replace('\\', "\\\\")
3436                        .replace('"', "\\\"");
3437                    format!(
3438                        r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
3439                        name,
3440                        l.code_lines,
3441                        l.comment_lines,
3442                        l.blank_lines,
3443                        l.functions,
3444                        l.classes,
3445                        l.variables,
3446                        l.imports,
3447                        l.files,
3448                    )
3449                })
3450                .collect();
3451            format!("[{}]", entries.join(","))
3452        },
3453        scatter_chart_json: {
3454            let entries: Vec<String> = run
3455                .totals_by_language
3456                .iter()
3457                .map(|l| {
3458                    let name = l
3459                        .language
3460                        .display_name()
3461                        .replace('\\', "\\\\")
3462                        .replace('"', "\\\"");
3463                    format!(
3464                        r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
3465                        name, l.files, l.code_lines, l.total_physical_lines,
3466                    )
3467                })
3468                .collect();
3469            format!("[{}]", entries.join(","))
3470        },
3471        semantic_chart_json: {
3472            let entries: Vec<String> = run
3473                .totals_by_language
3474                .iter()
3475                .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0)
3476                .map(|l| {
3477                    let name = l
3478                        .language
3479                        .display_name()
3480                        .replace('\\', "\\\\")
3481                        .replace('"', "\\\"");
3482                    format!(
3483                        r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
3484                        name, l.functions, l.classes, l.variables, l.imports,
3485                    )
3486                })
3487                .collect();
3488            format!("[{}]", entries.join(","))
3489        },
3490        submodule_chart_json: {
3491            let entries: Vec<String> = run
3492                .submodule_summaries
3493                .iter()
3494                .map(|s| {
3495                    let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
3496                    format!(
3497                        r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
3498                        name,
3499                        s.code_lines,
3500                        s.comment_lines,
3501                        s.blank_lines,
3502                        s.total_physical_lines,
3503                        s.files_analyzed,
3504                    )
3505                })
3506                .collect();
3507            format!("[{}]", entries.join(","))
3508        },
3509        has_submodule_data: !run.submodule_summaries.is_empty(),
3510        has_semantic_data: run
3511            .totals_by_language
3512            .iter()
3513            .any(|l| l.functions > 0 || l.classes > 0),
3514        csp_nonce: csp_nonce.to_owned(),
3515        confluence_configured,
3516        report_header_footer: run
3517            .effective_configuration
3518            .reporting
3519            .report_header_footer
3520            .clone(),
3521    };
3522
3523    Html(
3524        template
3525            .render()
3526            .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
3527    )
3528    .into_response()
3529}
3530
3531fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
3532    let slug: String = report_title
3533        .chars()
3534        .map(|c| {
3535            if c.is_alphanumeric() || c == '-' {
3536                c.to_ascii_lowercase()
3537            } else {
3538                '_'
3539            }
3540        })
3541        .collect::<String>()
3542        .split('_')
3543        .filter(|s| !s.is_empty())
3544        .collect::<Vec<_>>()
3545        .join("_");
3546
3547    let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
3548
3549    if slug.is_empty() {
3550        format!("report_{short_id}.pdf")
3551    } else {
3552        format!("{slug}_{short_id}.pdf")
3553    }
3554}
3555
3556#[derive(Serialize)]
3557struct PdfStatusResponse {
3558    ready: bool,
3559}
3560
3561/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
3562/// Clients poll this to update the button state without page reloads.
3563async fn pdf_status_handler(
3564    State(state): State<AppState>,
3565    AxumPath(run_id): AxumPath<String>,
3566) -> Response {
3567    let pdf_path = {
3568        let registry = state.artifacts.lock().await;
3569        registry.get(&run_id).and_then(|a| a.pdf_path.clone())
3570    };
3571    let pdf_path = if pdf_path.is_some() {
3572        pdf_path
3573    } else {
3574        let reg = state.registry.lock().await;
3575        reg.find_by_run_id(&run_id)
3576            .map(recover_artifacts_from_registry)
3577            .and_then(|a| a.pdf_path)
3578    };
3579    let ready = pdf_path.is_some_and(|p| p.exists());
3580    Json(PdfStatusResponse { ready }).into_response()
3581}
3582
3583/// Serve the HTML artifact for a run — view or download.
3584/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
3585/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
3586/// current-request Content-Security-Policy nonce check.
3587fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
3588    // Find the first nonce value that was baked in at render time.
3589    let Some(start) = html.find("nonce=\"") else {
3590        // Reports generated before nonce support was added have bare <style> and <script>
3591        // tags with no nonce attribute.  Inject the nonce so the current-request CSP allows
3592        // the inline blocks — without it the browser blocks all CSS and JS.
3593        return html
3594            .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
3595            .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
3596    };
3597    let value_start = start + 7; // len(r#"nonce=""#) == 7
3598    let Some(end_offset) = html[value_start..].find('"') else {
3599        return html.to_owned();
3600    };
3601    let old_nonce = &html[value_start..value_start + end_offset];
3602    html.replace(
3603        &format!("nonce=\"{old_nonce}\""),
3604        &format!("nonce=\"{new_nonce}\""),
3605    )
3606}
3607
3608fn serve_html_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3609    match fs::read_to_string(path) {
3610        Ok(raw) => {
3611            // Patch the saved nonce so inline styles/scripts pass CSP.
3612            let content = patch_html_nonce(&raw, csp_nonce);
3613            if wants_download {
3614                (
3615                    [
3616                        (header::CONTENT_TYPE, "text/html; charset=utf-8"),
3617                        (
3618                            header::CONTENT_DISPOSITION,
3619                            "attachment; filename=report.html",
3620                        ),
3621                    ],
3622                    content,
3623                )
3624                    .into_response()
3625            } else {
3626                Html(content).into_response()
3627            }
3628        }
3629        Err(err) => {
3630            let filename = path.file_name().map_or_else(
3631                || "report.html".to_string(),
3632                |n| n.to_string_lossy().into_owned(),
3633            );
3634            let msg = format!(
3635                "HTML report '{filename}' could not be read.\n\n\
3636                 Error: {err}\n\n\
3637                 If you moved or renamed the output folder, the stored path is now stale. \
3638                 Use 'Open HTML folder' from the results page to browse the output directory."
3639            );
3640            let html = ErrorTemplate {
3641                message: msg,
3642                last_report_url: Some("/view-reports".to_string()),
3643                last_report_label: Some("View Reports".to_string()),
3644                csp_nonce: csp_nonce.to_owned(),
3645            }
3646            .render()
3647            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3648            (StatusCode::NOT_FOUND, Html(html)).into_response()
3649        }
3650    }
3651}
3652
3653/// Serve the PDF artifact for a run — inline or download.
3654fn serve_pdf_artifact(
3655    path: &Path,
3656    report_title: &str,
3657    run_id: &str,
3658    wants_download: bool,
3659    csp_nonce: &str,
3660) -> Response {
3661    match fs::read(path) {
3662        Ok(bytes) => {
3663            let filename = build_pdf_filename(report_title, run_id);
3664            let disposition = if wants_download {
3665                format!("attachment; filename=\"{filename}\"")
3666            } else {
3667                format!("inline; filename=\"{filename}\"")
3668            };
3669            (
3670                [
3671                    (header::CONTENT_TYPE, "application/pdf".to_string()),
3672                    (header::CONTENT_DISPOSITION, disposition),
3673                ],
3674                bytes,
3675            )
3676                .into_response()
3677        }
3678        Err(err) => {
3679            let filename = path.file_name().map_or_else(
3680                || "report.pdf".to_string(),
3681                |n| n.to_string_lossy().into_owned(),
3682            );
3683            let msg = format!(
3684                "PDF report '{filename}' could not be read.\n\n\
3685                 Error: {err}\n\n\
3686                 If you moved or renamed the output folder, the stored path is now stale. \
3687                 Use 'Open PDF folder' from the results page to browse the output directory."
3688            );
3689            let html = ErrorTemplate {
3690                message: msg,
3691                last_report_url: Some("/view-reports".to_string()),
3692                last_report_label: Some("View Reports".to_string()),
3693                csp_nonce: csp_nonce.to_owned(),
3694            }
3695            .render()
3696            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3697            (StatusCode::NOT_FOUND, Html(html)).into_response()
3698        }
3699    }
3700}
3701
3702/// Serve the JSON artifact for a run — view or download.
3703fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
3704    match fs::read(path) {
3705        Ok(bytes) => {
3706            if wants_download {
3707                (
3708                    [
3709                        (header::CONTENT_TYPE, "application/json; charset=utf-8"),
3710                        (
3711                            header::CONTENT_DISPOSITION,
3712                            "attachment; filename=result.json",
3713                        ),
3714                    ],
3715                    bytes,
3716                )
3717                    .into_response()
3718            } else {
3719                (
3720                    [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
3721                    bytes,
3722                )
3723                    .into_response()
3724            }
3725        }
3726        Err(err) => {
3727            let filename = path.file_name().map_or_else(
3728                || "result.json".to_string(),
3729                |n| n.to_string_lossy().into_owned(),
3730            );
3731            let msg = format!(
3732                "JSON result '{filename}' could not be read.\n\n\
3733                 Error: {err}\n\n\
3734                 If you moved or renamed the output folder, the stored path is now stale. \
3735                 Use 'Open JSON folder' from the results page to browse the output directory."
3736            );
3737            let html = ErrorTemplate {
3738                message: msg,
3739                last_report_url: Some("/view-reports".to_string()),
3740                last_report_label: Some("View Reports".to_string()),
3741                csp_nonce: csp_nonce.to_owned(),
3742            }
3743            .render()
3744            .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
3745            (StatusCode::NOT_FOUND, Html(html)).into_response()
3746        }
3747    }
3748}
3749
3750/// Recover a `RunArtifacts` from the persisted registry for a run ID.
3751fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
3752    let output_dir = entry
3753        .html_path
3754        .as_ref()
3755        .or(entry.json_path.as_ref())
3756        .or(entry.pdf_path.as_ref())
3757        .or(entry.csv_path.as_ref())
3758        .or(entry.xlsx_path.as_ref())
3759        .and_then(|p| p.parent().map(PathBuf::from))
3760        .unwrap_or_default();
3761    // Recover pdf_path: use the persisted one, or look for report.pdf
3762    // adjacent to html/json if only the old entries lack it.
3763    let pdf_path = entry.pdf_path.clone().or_else(|| {
3764        let candidate = output_dir.join("report.pdf");
3765        candidate.exists().then_some(candidate)
3766    });
3767    // csv_path / xlsx_path: persisted paths take precedence; fall back to
3768    // scanning the run directory for files matching the expected patterns so
3769    // that runs created before this feature still surface their artifacts.
3770    let csv_path = entry.csv_path.clone().or_else(|| {
3771        fs::read_dir(&output_dir).ok().and_then(|entries| {
3772            entries
3773                .filter_map(std::result::Result::ok)
3774                .find(|e| {
3775                    let n = e.file_name();
3776                    let n = n.to_string_lossy();
3777                    n.starts_with("report_") && n.ends_with(".csv")
3778                })
3779                .map(|e| e.path())
3780        })
3781    });
3782    let xlsx_path = entry.xlsx_path.clone().or_else(|| {
3783        fs::read_dir(&output_dir).ok().and_then(|entries| {
3784            entries
3785                .filter_map(std::result::Result::ok)
3786                .find(|e| {
3787                    let n = e.file_name();
3788                    let n = n.to_string_lossy();
3789                    n.starts_with("report_") && n.ends_with(".xlsx")
3790                })
3791                .map(|e| e.path())
3792        })
3793    });
3794    RunArtifacts {
3795        output_dir: output_dir.clone(),
3796        html_path: entry.html_path.clone(),
3797        pdf_path,
3798        json_path: entry.json_path.clone(),
3799        csv_path,
3800        xlsx_path,
3801        scan_config_path: find_scan_config_in_dir(&output_dir),
3802        report_title: entry.project_label.clone(),
3803        result_context: RunResultContext::default(),
3804    }
3805}
3806
3807async fn resolve_artifact_set(
3808    state: &AppState,
3809    run_id: &str,
3810    csp_nonce: &str,
3811) -> Result<RunArtifacts, Response> {
3812    let cached = state.artifacts.lock().await.get(run_id).cloned();
3813    if let Some(a) = cached {
3814        return Ok(a);
3815    }
3816    let reg = state.registry.lock().await;
3817    if let Some(entry) = reg.find_by_run_id(run_id) {
3818        return Ok(recover_artifacts_from_registry(entry));
3819    }
3820    drop(reg);
3821    let short_id = &run_id[..run_id.len().min(8)];
3822    let hint = if matches!(
3823        run_id,
3824        "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
3825    ) {
3826        format!(
3827            " The URL format appears to be reversed — \
3828             the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
3829             Use the View Reports page to navigate to your scan."
3830        )
3831    } else {
3832        " The report may have been deleted or the report directory moved. \
3833         Use View Reports to browse your scan history."
3834            .to_string()
3835    };
3836    let error_html = ErrorTemplate {
3837        message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
3838        last_report_url: Some("/view-reports".to_string()),
3839        last_report_label: Some("View Reports".to_string()),
3840        csp_nonce: csp_nonce.to_owned(),
3841    }
3842    .render()
3843    .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
3844    Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
3845}
3846
3847#[allow(clippy::too_many_lines)] // bulk is an inline HTML string for the PDF-waiting page
3848async fn artifact_handler(
3849    State(state): State<AppState>,
3850    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3851    AxumPath((artifact, run_id)): AxumPath<(String, String)>,
3852    Query(query): Query<ArtifactQuery>,
3853) -> Response {
3854    let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
3855        Ok(a) => a,
3856        Err(r) => return r,
3857    };
3858
3859    let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
3860
3861    match artifact.as_str() {
3862        "html" => {
3863            let Some(path) = artifact_set.html_path else {
3864                return StatusCode::NOT_FOUND.into_response();
3865            };
3866            serve_html_artifact(&path, wants_download, &csp_nonce)
3867        }
3868        "pdf" => {
3869            let Some(path) = artifact_set.pdf_path else {
3870                let msg = "PDF report was not generated for this run, or was not recorded in \
3871                           the scan registry. Re-run the analysis with PDF output enabled."
3872                    .to_string();
3873                let html = ErrorTemplate {
3874                    message: msg,
3875                    last_report_url: Some(format!("/runs/html/{run_id}")),
3876                    last_report_label: Some("View HTML Report".to_string()),
3877                    csp_nonce: csp_nonce.clone(),
3878                }
3879                .render()
3880                .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
3881                return (StatusCode::NOT_FOUND, Html(html)).into_response();
3882            };
3883            // PDF path is recorded but the background task may still be writing it.
3884            // Return a self-refreshing "please wait" page rather than an error.
3885            if !path.exists() {
3886                let html = format!(
3887                    "<!doctype html><html lang=\"en\"><head>\
3888                     <meta charset=utf-8>\
3889                     <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
3890                     <meta http-equiv=\"refresh\" content=\"5\">\
3891                     <title>OxideSLOC | Generating PDF\u{2026}</title>\
3892                     <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
3893                     <style nonce=\"{csp_nonce}\">\
3894                     :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
3895                     --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
3896                     --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
3897                     body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
3898                     --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
3899                     *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
3900                     font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
3901                     background:var(--bg);color:var(--text);}}\
3902                     .top-nav{{position:sticky;top:0;z-index:30;\
3903                     background:linear-gradient(180deg,var(--nav),var(--nav-2));\
3904                     border-bottom:1px solid rgba(255,255,255,0.12);\
3905                     box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
3906                     .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
3907                     min-height:56px;display:flex;align-items:center;gap:14px;}}\
3908                     .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
3909                     .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
3910                     filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
3911                     .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
3912                     .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
3913                     .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
3914                     .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
3915                     .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
3916                     border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
3917                     background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
3918                     .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
3919                     .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
3920                     justify-content:center;min-height:38px;border-radius:999px;\
3921                     border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
3922                     .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
3923                     .theme-toggle .icon-sun{{display:none;}}\
3924                     body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
3925                     body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
3926                     .page{{max-width:1720px;margin:0 auto;padding:60px 24px;\
3927                     display:flex;align-items:center;justify-content:center;\
3928                     min-height:calc(100vh - 56px);}}\
3929                     .panel{{background:var(--surface);border:1px solid var(--line);\
3930                     border-radius:var(--radius);box-shadow:var(--shadow);\
3931                     padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
3932                     .spin-ring{{width:56px;height:56px;border-radius:50%;\
3933                     border:5px solid var(--line);border-top-color:var(--oxide-2);\
3934                     animation:spin 1s linear infinite;margin:0 auto 28px;}}\
3935                     @keyframes spin{{to{{transform:rotate(360deg);}}}}\
3936                     h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
3937                     p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
3938                     .back-link{{display:inline-flex;align-items:center;justify-content:center;\
3939                     min-height:42px;padding:0 20px;border-radius:14px;\
3940                     border:1px solid var(--line-strong);text-decoration:none;\
3941                     color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
3942                     .back-link:hover{{background:var(--line);}}\
3943                     </style></head>\
3944                     <body>\
3945                     <div class=\"top-nav\"><div class=\"top-nav-inner\">\
3946                       <a class=\"brand\" href=\"/\">\
3947                         <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
3948                         <div class=\"brand-copy\">\
3949                           <div class=\"brand-title\">OxideSLOC</div>\
3950                           <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
3951                         </div>\
3952                       </a>\
3953                       <div class=\"nav-right\">\
3954                         <a class=\"nav-pill\" href=\"/\">Home</a>\
3955                         <div class=\"nav-dropdown\">\
3956                           <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>\
3957                           <div class=\"nav-dropdown-menu\">\
3958                             <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>\
3959                           </div>\
3960                         </div>\
3961                         <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
3962                           <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>\
3963                           <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
3964                           <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>\
3965                         </button>\
3966                       </div>\
3967                     </div></div>\
3968                     <div class=\"page\"><div class=\"panel\">\
3969                       <div class=\"spin-ring\"></div>\
3970                       <h1>Generating PDF\u{2026}</h1>\
3971                       <p>The PDF is being rendered from the HTML report.<br>\
3972                       This page refreshes automatically \u{2014} usually 15\u{2013}45 seconds.</p>\
3973                       <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
3974                     </div></div>\
3975                     <script nonce=\"{csp_nonce}\">\
3976                     (function(){{\
3977                       var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
3978                       if(s===\"dark\")b.classList.add(\"dark-theme\");\
3979                       var t=document.getElementById(\"theme-toggle\");\
3980                       if(t)t.addEventListener(\"click\",function(){{\
3981                         var d=b.classList.toggle(\"dark-theme\");\
3982                         localStorage.setItem(k,d?\"dark\":\"light\");\
3983                       }});\
3984                     }})();\
3985                     </script>\
3986                     </body></html>"
3987                );
3988                return Html(html).into_response();
3989            }
3990            serve_pdf_artifact(
3991                &path,
3992                &artifact_set.report_title,
3993                &run_id,
3994                wants_download,
3995                &csp_nonce,
3996            )
3997        }
3998        "json" => {
3999            let Some(path) = artifact_set.json_path else {
4000                let msg = "JSON result was not generated for this run, or was not recorded in \
4001                           the scan registry. Re-run the analysis with JSON output enabled."
4002                    .to_string();
4003                let html = ErrorTemplate {
4004                    message: msg,
4005                    last_report_url: Some("/view-reports".to_string()),
4006                    last_report_label: Some("View Reports".to_string()),
4007                    csp_nonce: csp_nonce.clone(),
4008                }
4009                .render()
4010                .unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
4011                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4012            };
4013            serve_json_artifact(&path, wants_download, &csp_nonce)
4014        }
4015        "csv" => {
4016            let Some(path) = artifact_set.csv_path else {
4017                let msg = "CSV report was not generated for this run, or was not recorded in \
4018                           the scan registry."
4019                    .to_string();
4020                let html = ErrorTemplate {
4021                    message: msg,
4022                    last_report_url: Some(format!("/runs/html/{run_id}")),
4023                    last_report_label: Some("View HTML Report".to_string()),
4024                    csp_nonce: csp_nonce.clone(),
4025                }
4026                .render()
4027                .unwrap_or_else(|_| "<pre>CSV not available.</pre>".to_string());
4028                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4029            };
4030            fs::read(&path).map_or_else(
4031                |_| StatusCode::NOT_FOUND.into_response(),
4032                |bytes| {
4033                    let filename = path.file_name().map_or_else(
4034                        || "report.csv".to_string(),
4035                        |n| n.to_string_lossy().into_owned(),
4036                    );
4037                    (
4038                        [
4039                            (header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
4040                            (
4041                                header::CONTENT_DISPOSITION,
4042                                format!("attachment; filename=\"{filename}\""),
4043                            ),
4044                        ],
4045                        bytes,
4046                    )
4047                        .into_response()
4048                },
4049            )
4050        }
4051        "xlsx" => {
4052            let Some(path) = artifact_set.xlsx_path else {
4053                let msg = "Excel report was not generated for this run, or was not recorded in \
4054                           the scan registry."
4055                    .to_string();
4056                let html = ErrorTemplate {
4057                    message: msg,
4058                    last_report_url: Some(format!("/runs/html/{run_id}")),
4059                    last_report_label: Some("View HTML Report".to_string()),
4060                    csp_nonce: csp_nonce.clone(),
4061                }
4062                .render()
4063                .unwrap_or_else(|_| "<pre>Excel not available.</pre>".to_string());
4064                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4065            };
4066            fs::read(&path).map_or_else(
4067                |_| StatusCode::NOT_FOUND.into_response(),
4068                |bytes| {
4069                    let filename = path.file_name().map_or_else(
4070                        || "report.xlsx".to_string(),
4071                        |n| n.to_string_lossy().into_owned(),
4072                    );
4073                    (
4074                        [
4075                            (
4076                                header::CONTENT_TYPE,
4077                                "application/vnd.openxmlformats-officedocument\
4078                                 .spreadsheetml.sheet"
4079                                    .to_string(),
4080                            ),
4081                            (
4082                                header::CONTENT_DISPOSITION,
4083                                format!("attachment; filename=\"{filename}\""),
4084                            ),
4085                        ],
4086                        bytes,
4087                    )
4088                        .into_response()
4089                },
4090            )
4091        }
4092        "scan-config" => {
4093            let path = artifact_set
4094                .scan_config_path
4095                .as_deref()
4096                .map(std::path::Path::to_path_buf)
4097                .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
4098                .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
4099            fs::read(&path).map_or_else(
4100                |_| StatusCode::NOT_FOUND.into_response(),
4101                |bytes| {
4102                    (
4103                        [
4104                            (
4105                                header::CONTENT_TYPE,
4106                                "application/json; charset=utf-8".to_string(),
4107                            ),
4108                            (
4109                                header::CONTENT_DISPOSITION,
4110                                "attachment; filename=\"scan-config.json\"".to_string(),
4111                            ),
4112                        ],
4113                        bytes,
4114                    )
4115                        .into_response()
4116                },
4117            )
4118        }
4119        _ if artifact.starts_with("sub_") => {
4120            if artifact.len() > 128
4121                || !artifact
4122                    .chars()
4123                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4124            {
4125                return StatusCode::BAD_REQUEST.into_response();
4126            }
4127            let filename = format!("{artifact}.html");
4128            let path = artifact_set.output_dir.join(&filename);
4129            if !path.exists() {
4130                let html = ErrorTemplate {
4131                    message: format!(
4132                        "Sub-report '{artifact}' was not found in the run directory.\n\
4133                         Re-run the analysis with 'Detect and separate git submodules' \
4134                         and HTML output enabled."
4135                    ),
4136                    last_report_url: Some("/view-reports".to_string()),
4137                    last_report_label: Some("View Reports".to_string()),
4138                    csp_nonce: csp_nonce.clone(),
4139                }
4140                .render()
4141                .unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
4142                return (StatusCode::NOT_FOUND, Html(html)).into_response();
4143            }
4144            serve_html_artifact(&path, wants_download, &csp_nonce)
4145        }
4146        _ => StatusCode::NOT_FOUND.into_response(),
4147    }
4148}
4149
4150// ── History ───────────────────────────────────────────────────────────────────
4151
4152struct SubmoduleLinkRow {
4153    name: String,
4154    url: String,
4155}
4156
4157struct HistoryEntryRow {
4158    run_id: String,
4159    run_id_short: String,
4160    timestamp: String,
4161    timestamp_utc_ms: i64,
4162    project_label: String,
4163    project_path: String,
4164    files_analyzed: u64,
4165    files_skipped: u64,
4166    code_lines: u64,
4167    comment_lines: u64,
4168    blank_lines: u64,
4169    git_branch: String,
4170    git_commit: String,
4171    has_html: bool,
4172    has_json: bool,
4173    has_pdf: bool,
4174    submodule_links: Vec<SubmoduleLinkRow>,
4175    /// Comma-separated submodule names used as a `data-submodules` HTML attribute.
4176    submodule_names_csv: String,
4177}
4178
4179/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
4180fn nth_weekday_of_month(
4181    year: i32,
4182    month: u32,
4183    weekday: chrono::Weekday,
4184    n: u32,
4185) -> chrono::NaiveDate {
4186    use chrono::Datelike;
4187    let mut count = 0u32;
4188    let mut day = 1u32;
4189    loop {
4190        let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
4191        if d.weekday() == weekday {
4192            count += 1;
4193            if count == n {
4194                return d;
4195            }
4196        }
4197        day += 1;
4198    }
4199}
4200
4201/// Returns true if `dt` falls within US Pacific Daylight Time.
4202/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
4203/// DST ends:   first Sunday in November at 02:00 PDT = 09:00 UTC.
4204fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
4205    use chrono::{Datelike, TimeZone};
4206    let year = dt.year();
4207    let dst_start = chrono::Utc.from_utc_datetime(
4208        &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
4209            .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
4210    );
4211    let dst_end = chrono::Utc.from_utc_datetime(
4212        &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
4213            .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
4214    );
4215    dt >= dst_start && dt < dst_end
4216}
4217
4218fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
4219    if is_pacific_dst(dt) {
4220        dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
4221            .format("%Y-%m-%d %H:%M PDT")
4222            .to_string()
4223    } else {
4224        dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
4225            .format("%Y-%m-%d %H:%M PST")
4226            .to_string()
4227    }
4228}
4229
4230fn fmt_git_date(iso: &str) -> Option<String> {
4231    chrono::DateTime::parse_from_rfc3339(iso)
4232        .ok()
4233        .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
4234}
4235
4236fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
4237    reg.entries
4238        .iter()
4239        .map(|e| {
4240            let submodule_links = {
4241                let mut links: Vec<SubmoduleLinkRow> = vec![];
4242                let sub_dir = e
4243                    .html_path
4244                    .as_ref()
4245                    .and_then(|p| p.parent())
4246                    .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
4247                if let Some(dir) = sub_dir {
4248                    if let Ok(rd) = std::fs::read_dir(dir) {
4249                        for entry_res in rd.flatten() {
4250                            let fname = entry_res.file_name();
4251                            let fname_str = fname.to_string_lossy();
4252                            if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
4253                                let stem = &fname_str[..fname_str.len() - 5];
4254                                let display = stem[4..].replace('-', " ");
4255                                links.push(SubmoduleLinkRow {
4256                                    name: display,
4257                                    url: format!("/runs/{stem}/{}", e.run_id),
4258                                });
4259                            }
4260                        }
4261                    }
4262                }
4263                links.sort_by(|a, b| a.name.cmp(&b.name));
4264                links
4265            };
4266            let submodule_names_csv = submodule_links
4267                .iter()
4268                .map(|l| l.name.as_str())
4269                .collect::<Vec<_>>()
4270                .join(",");
4271            HistoryEntryRow {
4272                run_id: e.run_id.clone(),
4273                run_id_short: e
4274                    .run_id
4275                    .split('-')
4276                    .next_back()
4277                    .unwrap_or(&e.run_id)
4278                    .chars()
4279                    .take(7)
4280                    .collect(),
4281                timestamp: fmt_la_time(e.timestamp_utc),
4282                timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
4283                project_label: e.project_label.clone(),
4284                project_path: e
4285                    .input_roots
4286                    .first()
4287                    .map(|s| sanitize_path_str(s))
4288                    .unwrap_or_default(),
4289                files_analyzed: e.summary.files_analyzed,
4290                files_skipped: e.summary.files_skipped,
4291                code_lines: e.summary.code_lines,
4292                comment_lines: e.summary.comment_lines,
4293                blank_lines: e.summary.blank_lines,
4294                git_branch: e.git_branch.clone().unwrap_or_default(),
4295                git_commit: e.git_commit.clone().unwrap_or_default(),
4296                has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
4297                has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
4298                has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
4299                submodule_links,
4300                submodule_names_csv,
4301            }
4302        })
4303        .collect()
4304}
4305
4306#[derive(Deserialize, Default)]
4307struct HistoryQuery {
4308    linked: Option<String>,
4309    error: Option<String>,
4310}
4311
4312async fn history_handler(
4313    State(state): State<AppState>,
4314    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4315    Query(query): Query<HistoryQuery>,
4316) -> impl IntoResponse {
4317    // Auto-scan all watched directories before rendering so the list stays fresh.
4318    auto_scan_watched_dirs(&state).await;
4319    let watched_dirs: Vec<String> = {
4320        let wd = state.watched_dirs.lock().await;
4321        wd.dirs.iter().map(|p| p.display().to_string()).collect()
4322    };
4323    let mut entries = {
4324        let reg = state.registry.lock().await;
4325        make_history_rows(&reg)
4326    };
4327    entries.retain(|e| e.has_html);
4328    let total_scans = entries.len();
4329    let linked_count = query
4330        .linked
4331        .as_deref()
4332        .and_then(|s| s.parse::<usize>().ok())
4333        .unwrap_or(0);
4334    let browse_error = query.error.filter(|s| !s.is_empty());
4335    let template = HistoryTemplate {
4336        version: env!("CARGO_PKG_VERSION"),
4337        entries,
4338        total_scans,
4339        linked_count,
4340        browse_error,
4341        watched_dirs,
4342        csp_nonce,
4343    };
4344    Html(
4345        template
4346            .render()
4347            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4348    )
4349    .into_response()
4350}
4351
4352async fn compare_select_handler(
4353    State(state): State<AppState>,
4354    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4355) -> impl IntoResponse {
4356    auto_scan_watched_dirs(&state).await;
4357    let watched_dirs: Vec<String> = {
4358        let wd = state.watched_dirs.lock().await;
4359        wd.dirs.iter().map(|p| p.display().to_string()).collect()
4360    };
4361    let mut entries = {
4362        let reg = state.registry.lock().await;
4363        make_history_rows(&reg)
4364    };
4365    entries.retain(|e| e.has_json);
4366    let total_scans = entries.len();
4367    let template = CompareSelectTemplate {
4368        version: env!("CARGO_PKG_VERSION"),
4369        entries,
4370        total_scans,
4371        watched_dirs,
4372        csp_nonce,
4373    };
4374    Html(
4375        template
4376            .render()
4377            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4378    )
4379    .into_response()
4380}
4381
4382// ── Compare ───────────────────────────────────────────────────────────────────
4383
4384#[derive(Deserialize, Default)]
4385struct CompareQuery {
4386    a: Option<String>,
4387    b: Option<String>,
4388    /// Optional submodule name to scope the comparison to one submodule.
4389    sub: Option<String>,
4390    /// "super" to exclude all submodule files and show only the super-repo.
4391    scope: Option<String>,
4392}
4393
4394struct CompareFileDeltaRow {
4395    relative_path: String,
4396    language: String,
4397    status: String,
4398    baseline_code: i64,
4399    current_code: i64,
4400    code_delta_str: String,
4401    code_delta_class: String,
4402    comment_delta_str: String,
4403    comment_delta_class: String,
4404    total_delta_str: String,
4405    total_delta_class: String,
4406}
4407
4408/// Recompute `summary_totals` from the current `per_file_records` slice.
4409/// Used when `per_file_records` has been narrowed to a submodule subset.
4410fn recompute_summary_from_records(run: &mut AnalysisRun) {
4411    let files_analyzed = run
4412        .per_file_records
4413        .iter()
4414        .filter(|r| r.language.is_some())
4415        .count() as u64;
4416    let code_lines: u64 = run
4417        .per_file_records
4418        .iter()
4419        .map(|r| r.effective_counts.code_lines)
4420        .sum();
4421    let comment_lines: u64 = run
4422        .per_file_records
4423        .iter()
4424        .map(|r| r.effective_counts.comment_lines)
4425        .sum();
4426    let blank_lines: u64 = run
4427        .per_file_records
4428        .iter()
4429        .map(|r| r.effective_counts.blank_lines)
4430        .sum();
4431    run.summary_totals.files_analyzed = files_analyzed;
4432    run.summary_totals.files_considered = files_analyzed;
4433    run.summary_totals.code_lines = code_lines;
4434    run.summary_totals.comment_lines = comment_lines;
4435    run.summary_totals.blank_lines = blank_lines;
4436    run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
4437}
4438
4439fn fmt_delta(n: i64) -> String {
4440    if n > 0 {
4441        format!("+{n}")
4442    } else {
4443        format!("{n}")
4444    }
4445}
4446
4447fn delta_class(n: i64) -> &'static str {
4448    use std::cmp::Ordering;
4449    match n.cmp(&0) {
4450        Ordering::Greater => "pos",
4451        Ordering::Less => "neg",
4452        Ordering::Equal => "zero",
4453    }
4454}
4455
4456// ratio/percentage display, precision loss acceptable
4457#[allow(clippy::cast_precision_loss)]
4458fn fmt_pct(delta: i64, baseline: u64) -> String {
4459    if baseline == 0 {
4460        return "—".to_string();
4461    }
4462    #[allow(clippy::cast_precision_loss)]
4463    let pct = (delta as f64 / baseline as f64) * 100.0;
4464    if pct > 0.049 {
4465        format!("+{pct:.1}%")
4466    } else if pct < -0.049 {
4467        format!("{pct:.1}%")
4468    } else {
4469        "±0%".to_string()
4470    }
4471}
4472
4473/// Returns (`display_string`, `css_class`) for a numeric change column cell.
4474fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
4475    prev.map_or_else(
4476        || ("—".to_string(), "na"),
4477        |p| {
4478            #[allow(clippy::cast_possible_wrap)]
4479            let d = curr as i64 - p as i64;
4480            (fmt_delta(d), delta_class(d))
4481        },
4482    )
4483}
4484
4485#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
4486fn load_scan_for_compare(
4487    json_path: &std::path::Path,
4488    scan_label: &str,
4489    run_id: &str,
4490    server_mode: bool,
4491    compare_url: &str,
4492    csp_nonce: &str,
4493) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
4494    match read_json(json_path) {
4495        Ok(r) => Ok(r),
4496        Err(e) => {
4497            if server_mode {
4498                let html = ErrorTemplate {
4499                    message: format!(
4500                        "Could not load {scan_label} scan data. The scan output folder may have \
4501                         been moved, renamed, or deleted. Re-running the analysis will create \
4502                         fresh comparison data."
4503                    ),
4504                    last_report_url: Some("/compare-scans".to_string()),
4505                    last_report_label: Some("Compare Scans".to_string()),
4506                    csp_nonce: csp_nonce.to_owned(),
4507                }
4508                .render()
4509                .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
4510                return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
4511            }
4512            let msg = format!(
4513                "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
4514                json_path.display()
4515            );
4516            let folder_hint = json_path
4517                .parent()
4518                .map(|p| p.display().to_string())
4519                .unwrap_or_default();
4520            Err(missing_scan_relocate_response(
4521                &msg,
4522                run_id,
4523                &folder_hint,
4524                compare_url,
4525                false,
4526                csp_nonce,
4527            ))
4528        }
4529    }
4530}
4531
4532struct ChurnStats {
4533    new_scope: bool,
4534    scope_flag: bool,
4535    churn_rate_str: String,
4536    churn_rate_class: String,
4537}
4538
4539fn compute_churn_stats(
4540    baseline_code: u64,
4541    current_code: u64,
4542    lines_added: i64,
4543    lines_removed: i64,
4544) -> ChurnStats {
4545    let new_scope = baseline_code == 0 && current_code > 0;
4546    #[allow(clippy::cast_precision_loss)]
4547    let churn_pct = if baseline_code > 0 {
4548        (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
4549    } else {
4550        0.0
4551    };
4552    #[allow(clippy::cast_precision_loss)]
4553    let scope_flag =
4554        new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
4555    let churn_rate_str = if new_scope {
4556        "New".to_string()
4557    } else if baseline_code > 0 {
4558        format!("{churn_pct:.1}%")
4559    } else {
4560        "—".to_string()
4561    };
4562    let churn_rate_class = if new_scope || churn_pct > 20.0 {
4563        "high".to_string()
4564    } else if churn_pct > 5.0 {
4565        "med".to_string()
4566    } else {
4567        "low".to_string()
4568    };
4569    ChurnStats {
4570        new_scope,
4571        scope_flag,
4572        churn_rate_str,
4573        churn_rate_class,
4574    }
4575}
4576
4577#[allow(clippy::too_many_lines)]
4578async fn compare_handler(
4579    State(state): State<AppState>,
4580    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4581    Query(query): Query<CompareQuery>,
4582) -> impl IntoResponse {
4583    // When invoked without run IDs (e.g. clicking the Compare nav link directly)
4584    // redirect to the history page where the user can select two runs.
4585    let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
4586        (Some(a), Some(b)) => (a.to_string(), b.to_string()),
4587        _ => return axum::response::Redirect::to("/compare-scans").into_response(),
4588    };
4589
4590    let (maybe_a, maybe_b) = {
4591        let reg = state.registry.lock().await;
4592        (
4593            reg.find_by_run_id(&run_id_a).cloned(),
4594            reg.find_by_run_id(&run_id_b).cloned(),
4595        )
4596    };
4597
4598    let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
4599        let html = ErrorTemplate {
4600            message: "One or both run IDs were not found in scan history. \
4601                      The runs may have been deleted or the registry may have been reset."
4602                .to_string(),
4603            last_report_url: Some("/compare-scans".to_string()),
4604            last_report_label: Some("Compare Scans".to_string()),
4605            csp_nonce: csp_nonce.clone(),
4606        }
4607        .render()
4608        .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
4609        return Html(html).into_response();
4610    };
4611
4612    // Ensure older scan is always the baseline.
4613    let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
4614        (entry_a, entry_b)
4615    } else {
4616        (entry_b, entry_a)
4617    };
4618
4619    // If query params were in the wrong order, redirect to canonical URL so the
4620    // browser always shows the same URL for the same two scans regardless of how
4621    // the user arrived here (Full diff button vs. Compare Scans selection).
4622    if baseline_entry.run_id != run_id_a {
4623        let canonical = format!(
4624            "/compare?a={}&b={}",
4625            baseline_entry.run_id, current_entry.run_id
4626        );
4627        return axum::response::Redirect::to(&canonical).into_response();
4628    }
4629
4630    let (Some(base_json), Some(curr_json)) = (
4631        baseline_entry.json_path.as_ref(),
4632        current_entry.json_path.as_ref(),
4633    ) else {
4634        let html = ErrorTemplate {
4635            message: "Full comparison requires JSON scan data, which was not saved for one or \
4636                      both of these runs. JSON is now always saved for new scans — re-run the \
4637                      affected projects to enable comparisons."
4638                .to_string(),
4639            last_report_url: Some("/compare-scans".to_string()),
4640            last_report_label: Some("Compare Scans".to_string()),
4641            csp_nonce: csp_nonce.clone(),
4642        }
4643        .render()
4644        .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
4645        return Html(html).into_response();
4646    };
4647
4648    let compare_url = format!(
4649        "/compare?a={}&b={}",
4650        baseline_entry.run_id, current_entry.run_id
4651    );
4652
4653    let baseline_run = match load_scan_for_compare(
4654        base_json,
4655        "baseline",
4656        &baseline_entry.run_id,
4657        state.server_mode,
4658        &compare_url,
4659        &csp_nonce,
4660    ) {
4661        Ok(r) => r,
4662        Err(resp) => return resp,
4663    };
4664    let current_run = match load_scan_for_compare(
4665        curr_json,
4666        "current",
4667        &current_entry.run_id,
4668        state.server_mode,
4669        &compare_url,
4670        &csp_nonce,
4671    ) {
4672        Ok(r) => r,
4673        Err(resp) => return resp,
4674    };
4675
4676    let active_submodule = query.sub.clone();
4677    let super_scope_active = query.scope.as_deref() == Some("super");
4678
4679    let submodule_options = baseline_run
4680        .submodule_summaries
4681        .iter()
4682        .chain(current_run.submodule_summaries.iter())
4683        .map(|s| s.name.clone())
4684        .collect::<std::collections::BTreeSet<_>>()
4685        .into_iter()
4686        .collect::<Vec<_>>();
4687    let has_any_submodule_data = !submodule_options.is_empty();
4688
4689    // Narrow per_file_records when a scope is active, then recompute totals.
4690    let (effective_baseline, effective_current) = if let Some(ref sub_name) = active_submodule {
4691        let mut b = baseline_run;
4692        let mut c = current_run;
4693        b.per_file_records
4694            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4695        c.per_file_records
4696            .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
4697        recompute_summary_from_records(&mut b);
4698        recompute_summary_from_records(&mut c);
4699        (b, c)
4700    } else if super_scope_active {
4701        let mut b = baseline_run;
4702        let mut c = current_run;
4703        b.per_file_records.retain(|f| f.submodule.is_none());
4704        c.per_file_records.retain(|f| f.submodule.is_none());
4705        recompute_summary_from_records(&mut b);
4706        recompute_summary_from_records(&mut c);
4707        (b, c)
4708    } else {
4709        (baseline_run, current_run)
4710    };
4711
4712    let comparison = compute_delta(&effective_baseline, &effective_current);
4713
4714    let file_rows: Vec<CompareFileDeltaRow> = comparison
4715        .file_deltas
4716        .iter()
4717        .map(|d| CompareFileDeltaRow {
4718            relative_path: d.relative_path.clone(),
4719            language: d.language.clone().unwrap_or_else(|| "—".into()),
4720            status: match d.status {
4721                FileChangeStatus::Added => "added".into(),
4722                FileChangeStatus::Removed => "removed".into(),
4723                FileChangeStatus::Modified => "modified".into(),
4724                FileChangeStatus::Unchanged => "unchanged".into(),
4725            },
4726            baseline_code: d.baseline_code,
4727            current_code: d.current_code,
4728            code_delta_str: fmt_delta(d.code_delta),
4729            code_delta_class: delta_class(d.code_delta).into(),
4730            comment_delta_str: fmt_delta(d.comment_delta),
4731            comment_delta_class: delta_class(d.comment_delta).into(),
4732            total_delta_str: fmt_delta(d.total_delta),
4733            total_delta_class: delta_class(d.total_delta).into(),
4734        })
4735        .collect();
4736
4737    let project_path = baseline_entry
4738        .input_roots
4739        .first()
4740        .map(|s| sanitize_path_str(s))
4741        .unwrap_or_default();
4742    let lines_added = sum_added_code_lines(&comparison);
4743    let lines_removed = sum_removed_code_lines(&comparison);
4744    let churn = compute_churn_stats(
4745        comparison.summary.baseline_code,
4746        comparison.summary.current_code,
4747        lines_added,
4748        lines_removed,
4749    );
4750    let s = &comparison.summary;
4751    let template = CompareTemplate {
4752        version: env!("CARGO_PKG_VERSION"),
4753        project_label: baseline_entry.project_label.clone(),
4754        baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
4755        current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
4756        baseline_run_id: baseline_entry.run_id.clone(),
4757        current_run_id: current_entry.run_id.clone(),
4758        baseline_run_id_short: baseline_entry
4759            .run_id
4760            .split('-')
4761            .next_back()
4762            .unwrap_or(&baseline_entry.run_id)
4763            .chars()
4764            .take(7)
4765            .collect(),
4766        current_run_id_short: current_entry
4767            .run_id
4768            .split('-')
4769            .next_back()
4770            .unwrap_or(&current_entry.run_id)
4771            .chars()
4772            .take(7)
4773            .collect(),
4774        baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
4775        baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
4776        current_timestamp: fmt_la_time(current_entry.timestamp_utc),
4777        current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
4778        project_path: project_path.clone(),
4779        baseline_code: s.baseline_code,
4780        current_code: s.current_code,
4781        code_lines_delta_str: fmt_delta(s.code_lines_delta),
4782        code_lines_delta_class: delta_class(s.code_lines_delta).into(),
4783        baseline_files: s.baseline_files,
4784        current_files: s.current_files,
4785        files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
4786        files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
4787        baseline_comments: s.baseline_comments,
4788        current_comments: s.current_comments,
4789        comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
4790        comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
4791        code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
4792        files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
4793        comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
4794        code_lines_added: lines_added,
4795        code_lines_removed: lines_removed,
4796        new_scope: churn.new_scope,
4797        churn_rate_str: churn.churn_rate_str,
4798        churn_rate_class: churn.churn_rate_class,
4799        scope_flag: churn.scope_flag,
4800        files_added: comparison.files_added,
4801        files_removed: comparison.files_removed,
4802        files_modified: comparison.files_modified,
4803        files_unchanged: comparison.files_unchanged,
4804        file_rows,
4805        baseline_git_author: baseline_entry.git_author.clone(),
4806        current_git_author: current_entry.git_author.clone(),
4807        baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
4808        current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
4809        baseline_git_tags: baseline_entry.git_tags.clone(),
4810        current_git_tags: current_entry.git_tags.clone(),
4811        baseline_git_commit_date: baseline_entry
4812            .git_commit_date
4813            .as_deref()
4814            .and_then(fmt_git_date),
4815        current_git_commit_date: current_entry
4816            .git_commit_date
4817            .as_deref()
4818            .and_then(fmt_git_date),
4819        project_name: project_path
4820            .rsplit(['/', '\\'])
4821            .find(|s| !s.is_empty())
4822            .unwrap_or(&project_path)
4823            .to_string(),
4824        submodule_options,
4825        has_any_submodule_data,
4826        active_submodule,
4827        super_scope_active,
4828        csp_nonce,
4829    };
4830
4831    Html(
4832        template
4833            .render()
4834            .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4835    )
4836    .into_response()
4837}
4838
4839// ── Badge endpoint ────────────────────────────────────────────────────────────
4840// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
4841// pages, Jira descriptions, etc.
4842//
4843// GET /badge/<metric>?label=<override>&color=<hex>
4844// Metrics: code-lines  files  comment-lines  blank-lines
4845
4846fn format_number(n: u64) -> String {
4847    let s = n.to_string();
4848    let mut out = String::with_capacity(s.len() + s.len() / 3);
4849    let len = s.len();
4850    for (i, c) in s.chars().enumerate() {
4851        if i > 0 && (len - i).is_multiple_of(3) {
4852            out.push(',');
4853        }
4854        out.push(c);
4855    }
4856    out
4857}
4858
4859const fn badge_char_width(c: char) -> f64 {
4860    match c {
4861        'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
4862        'm' | 'w' => 9.0,
4863        ' ' => 4.0,
4864        _ => 6.5,
4865    }
4866}
4867
4868#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4869fn badge_text_px(text: &str) -> u32 {
4870    text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
4871}
4872
4873fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
4874    let lw = badge_text_px(label) + 20;
4875    let rw = badge_text_px(value) + 20;
4876    let total = lw + rw;
4877    let lx = lw / 2;
4878    let rx = lw + rw / 2;
4879    let le = escape_html(label);
4880    let ve = escape_html(value);
4881    let ce = escape_html(color);
4882    format!(
4883        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
4884  <rect width="{total}" height="20" fill="#555"/>
4885  <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
4886  <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
4887    <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
4888    <text x="{lx}" y="13">{le}</text>
4889    <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
4890    <text x="{rx}" y="13">{ve}</text>
4891  </g>
4892</svg>"##
4893    )
4894}
4895
4896#[derive(Deserialize)]
4897struct BadgeQuery {
4898    label: Option<String>,
4899    color: Option<String>,
4900}
4901
4902async fn badge_handler(
4903    State(state): State<AppState>,
4904    AxumPath(metric): AxumPath<String>,
4905    Query(query): Query<BadgeQuery>,
4906) -> Response {
4907    let entry = {
4908        let reg = state.registry.lock().await;
4909        reg.entries.first().cloned()
4910    };
4911
4912    let Some(entry) = entry else {
4913        let svg = render_badge_svg("oxide-sloc", "no data", "#999");
4914        return (
4915            [
4916                (header::CONTENT_TYPE, "image/svg+xml"),
4917                (header::CACHE_CONTROL, "no-cache, max-age=0"),
4918            ],
4919            svg,
4920        )
4921            .into_response();
4922    };
4923
4924    let (default_label, value, default_color) = match metric.as_str() {
4925        "code-lines" => (
4926            "code lines",
4927            format_number(entry.summary.code_lines),
4928            "#4a78ee",
4929        ),
4930        "files" => (
4931            "files analyzed",
4932            format_number(entry.summary.files_analyzed),
4933            "#4a9862",
4934        ),
4935        "comment-lines" => (
4936            "comment lines",
4937            format_number(entry.summary.comment_lines),
4938            "#b35428",
4939        ),
4940        "blank-lines" => (
4941            "blank lines",
4942            format_number(entry.summary.blank_lines),
4943            "#7a5db0",
4944        ),
4945        _ => return StatusCode::NOT_FOUND.into_response(),
4946    };
4947
4948    let label = query.label.as_deref().unwrap_or(default_label);
4949    let color = query.color.as_deref().unwrap_or(default_color);
4950    let svg = render_badge_svg(label, &value, color);
4951
4952    (
4953        [
4954            (header::CONTENT_TYPE, "image/svg+xml"),
4955            (header::CACHE_CONTROL, "no-cache, max-age=0"),
4956        ],
4957        svg,
4958    )
4959        .into_response()
4960}
4961
4962// ── Metrics API ───────────────────────────────────────────────────────────────
4963// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
4964// Confluence automation, Jira webhooks, etc.
4965//
4966// GET /api/metrics/latest
4967// GET /api/metrics/<run_id>
4968
4969#[derive(Serialize)]
4970struct ApiMetricsResponse {
4971    run_id: String,
4972    timestamp: String,
4973    project: String,
4974    summary: ApiSummaryPayload,
4975    languages: Vec<ApiLanguageRow>,
4976}
4977
4978#[derive(Serialize)]
4979struct ApiSummaryPayload {
4980    files_analyzed: u64,
4981    files_skipped: u64,
4982    code_lines: u64,
4983    comment_lines: u64,
4984    blank_lines: u64,
4985    total_physical_lines: u64,
4986    functions: u64,
4987    classes: u64,
4988    variables: u64,
4989    imports: u64,
4990}
4991
4992#[derive(Serialize)]
4993struct ApiLanguageRow {
4994    name: String,
4995    files: u64,
4996    code_lines: u64,
4997    comment_lines: u64,
4998    blank_lines: u64,
4999    functions: u64,
5000    classes: u64,
5001    variables: u64,
5002    imports: u64,
5003}
5004
5005async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
5006    let entry = {
5007        let reg = state.registry.lock().await;
5008        reg.entries.first().cloned()
5009    };
5010    entry.map_or_else(
5011        || error::not_found("no scans recorded yet"),
5012        |e| build_metrics_response(&e),
5013    )
5014}
5015
5016async fn api_metrics_run_handler(
5017    State(state): State<AppState>,
5018    AxumPath(run_id): AxumPath<String>,
5019) -> Response {
5020    let entry = {
5021        let reg = state.registry.lock().await;
5022        reg.find_by_run_id(&run_id).cloned()
5023    };
5024    entry.map_or_else(
5025        || error::not_found("run not found"),
5026        |e| build_metrics_response(&e),
5027    )
5028}
5029
5030fn build_metrics_response(entry: &RegistryEntry) -> Response {
5031    let languages: Vec<ApiLanguageRow> = entry
5032        .json_path
5033        .as_ref()
5034        .and_then(|p| read_json(p).ok())
5035        .map(|run| {
5036            run.totals_by_language
5037                .iter()
5038                .map(|l| ApiLanguageRow {
5039                    name: l.language.display_name().to_string(),
5040                    files: l.files,
5041                    code_lines: l.code_lines,
5042                    comment_lines: l.comment_lines,
5043                    blank_lines: l.blank_lines,
5044                    functions: l.functions,
5045                    classes: l.classes,
5046                    variables: l.variables,
5047                    imports: l.imports,
5048                })
5049                .collect()
5050        })
5051        .unwrap_or_default();
5052
5053    let s = &entry.summary;
5054    Json(ApiMetricsResponse {
5055        run_id: entry.run_id.clone(),
5056        timestamp: entry.timestamp_utc.to_rfc3339(),
5057        project: entry.project_label.clone(),
5058        summary: ApiSummaryPayload {
5059            files_analyzed: s.files_analyzed,
5060            files_skipped: s.files_skipped,
5061            code_lines: s.code_lines,
5062            comment_lines: s.comment_lines,
5063            blank_lines: s.blank_lines,
5064            total_physical_lines: s.total_physical_lines,
5065            functions: s.functions,
5066            classes: s.classes,
5067            variables: s.variables,
5068            imports: s.imports,
5069        },
5070        languages,
5071    })
5072    .into_response()
5073}
5074
5075// ── Project history API ───────────────────────────────────────────────────────
5076// Protected. Called by the wizard JS when the project path changes, so the UI
5077// can show a "scanned N times before" badge without a full page reload.
5078//
5079// GET /api/project-history?path=<project_root>
5080
5081#[derive(Deserialize)]
5082struct ProjectHistoryQuery {
5083    path: Option<String>,
5084}
5085
5086#[derive(Serialize)]
5087struct ProjectHistoryResponse {
5088    scan_count: usize,
5089    last_scan_id: Option<String>,
5090    last_scan_timestamp: Option<String>,
5091    last_scan_code_lines: Option<u64>,
5092    last_git_branch: Option<String>,
5093    last_git_commit: Option<String>,
5094}
5095
5096async fn project_history_handler(
5097    State(state): State<AppState>,
5098    Query(query): Query<ProjectHistoryQuery>,
5099) -> Response {
5100    let path = query.path.unwrap_or_default();
5101    let resolved = resolve_input_path(&path);
5102    let root_str = resolved.to_string_lossy().replace('\\', "/");
5103
5104    let entries: Vec<_> = {
5105        let reg = state.registry.lock().await;
5106        reg.entries
5107            .iter()
5108            .filter(|e| e.input_roots.iter().any(|r| r == &root_str))
5109            .cloned()
5110            .collect()
5111    };
5112    let scan_count = entries.len();
5113    let last = entries.first();
5114    let last_scan_id = last.map(|e| e.run_id.clone());
5115    let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
5116    let last_scan_code_lines = last.map(|e| e.summary.code_lines);
5117    let last_git_branch = last.and_then(|e| e.git_branch.clone());
5118    let last_git_commit = last.and_then(|e| e.git_commit.clone());
5119
5120    Json(ProjectHistoryResponse {
5121        scan_count,
5122        last_scan_id,
5123        last_scan_timestamp,
5124        last_scan_code_lines,
5125        last_git_branch,
5126        last_git_commit,
5127    })
5128    .into_response()
5129}
5130
5131// ── Metrics history API ───────────────────────────────────────────────────────
5132// Protected. Returns a JSON array of lightweight scan snapshots for plotting
5133// trend charts.
5134//
5135// GET /api/metrics/history?root=<path>&limit=<n>
5136
5137#[derive(Deserialize)]
5138struct MetricsHistoryQuery {
5139    root: Option<String>,
5140    limit: Option<usize>,
5141    /// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
5142    /// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
5143    submodule: Option<String>,
5144}
5145
5146#[derive(Serialize)]
5147struct MetricsSubmoduleLink {
5148    name: String,
5149    url: String,
5150}
5151
5152#[derive(Serialize)]
5153struct MetricsHistoryEntry {
5154    run_id: String,
5155    run_id_short: String,
5156    timestamp: String,
5157    commit: Option<String>,
5158    branch: Option<String>,
5159    tags: Vec<String>,
5160    nearest_tag: Option<String>,
5161    code_lines: u64,
5162    comment_lines: u64,
5163    blank_lines: u64,
5164    physical_lines: u64,
5165    files_analyzed: u64,
5166    files_skipped: u64,
5167    test_count: u64,
5168    project_label: String,
5169    html_url: Option<String>,
5170    has_pdf: bool,
5171    submodule_links: Vec<MetricsSubmoduleLink>,
5172}
5173
5174fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
5175    let mut links: Vec<MetricsSubmoduleLink> = vec![];
5176    let sub_dir = e
5177        .html_path
5178        .as_ref()
5179        .and_then(|p| p.parent())
5180        .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
5181    let Some(dir) = sub_dir else { return links };
5182    let Ok(rd) = std::fs::read_dir(dir) else {
5183        return links;
5184    };
5185    for entry_res in rd.flatten() {
5186        let fname = entry_res.file_name();
5187        let fname_str = fname.to_string_lossy();
5188        if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
5189            let stem = &fname_str[..fname_str.len() - 5];
5190            let display = stem[4..].replace('-', " ");
5191            links.push(MetricsSubmoduleLink {
5192                name: display,
5193                url: format!("/runs/{stem}/{}", e.run_id),
5194            });
5195        }
5196    }
5197    links.sort_by(|a, b| a.name.cmp(&b.name));
5198    links
5199}
5200
5201fn apply_submodule_filter(
5202    base: MetricsHistoryEntry,
5203    filter: &str,
5204    e: &sloc_core::history::RegistryEntry,
5205) -> Option<MetricsHistoryEntry> {
5206    let json_path = e.json_path.as_ref()?;
5207    let json_str = std::fs::read_to_string(json_path).ok()?;
5208    let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
5209    let sub = run
5210        .submodule_summaries
5211        .iter()
5212        .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
5213    let safe = sanitize_project_label(&sub.name);
5214    let artifact_key = format!("sub_{safe}");
5215    let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
5216        || base.html_url.clone(),
5217        |run_dir| {
5218            let sub_path = run_dir.join(format!("{artifact_key}.html"));
5219            if sub_path.exists() {
5220                Some(format!("/runs/{artifact_key}/{}", e.run_id))
5221            } else {
5222                base.html_url.clone()
5223            }
5224        },
5225    );
5226    Some(MetricsHistoryEntry {
5227        code_lines: sub.code_lines,
5228        comment_lines: sub.comment_lines,
5229        blank_lines: sub.blank_lines,
5230        physical_lines: sub.total_physical_lines,
5231        files_analyzed: sub.files_analyzed,
5232        html_url: sub_html_url,
5233        has_pdf: false,
5234        submodule_links: vec![],
5235        ..base
5236    })
5237}
5238
5239#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
5240async fn api_metrics_history_handler(
5241    State(state): State<AppState>,
5242    Query(query): Query<MetricsHistoryQuery>,
5243) -> Response {
5244    let limit = query.limit.unwrap_or(50).min(500);
5245    let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
5246
5247    let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
5248        let reg = state.registry.lock().await;
5249        reg.entries
5250            .iter()
5251            .filter(|e| {
5252                query.root.as_ref().is_none_or(|root| {
5253                    let resolved = resolve_input_path(root);
5254                    let root_str = resolved.to_string_lossy().replace('\\', "/");
5255                    e.input_roots.iter().any(|r| r == &root_str)
5256                })
5257            })
5258            .take(limit)
5259            .cloned()
5260            .collect()
5261    };
5262
5263    let entries: Vec<MetricsHistoryEntry> = candidate_entries
5264        .into_iter()
5265        .filter_map(|e| {
5266            let tags = e
5267                .git_tags
5268                .as_deref()
5269                .map(|s| {
5270                    s.split(',')
5271                        .map(|t| t.trim().to_string())
5272                        .filter(|t| !t.is_empty())
5273                        .collect()
5274                })
5275                .unwrap_or_default();
5276            let html_url = e
5277                .html_path
5278                .as_ref()
5279                .filter(|p| p.exists())
5280                .map(|_| format!("/runs/html/{}", e.run_id));
5281            let nearest_tag = e.git_nearest_tag.clone();
5282            let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
5283            let run_id_short: String = e
5284                .run_id
5285                .split('-')
5286                .next_back()
5287                .unwrap_or(&e.run_id)
5288                .chars()
5289                .take(7)
5290                .collect();
5291            let submodule_links = build_entry_submodule_links(&e);
5292            let base = MetricsHistoryEntry {
5293                run_id: e.run_id.clone(),
5294                run_id_short,
5295                timestamp: e.timestamp_utc.to_rfc3339(),
5296                commit: e.git_commit.clone(),
5297                branch: e.git_branch.clone(),
5298                tags,
5299                nearest_tag,
5300                code_lines: e.summary.code_lines,
5301                comment_lines: e.summary.comment_lines,
5302                blank_lines: e.summary.blank_lines,
5303                physical_lines: e.summary.total_physical_lines,
5304                files_analyzed: e.summary.files_analyzed,
5305                files_skipped: e.summary.files_skipped,
5306                test_count: e.summary.test_count,
5307                project_label: e.project_label.clone(),
5308                html_url,
5309                has_pdf,
5310                submodule_links,
5311            };
5312            if let Some(ref filter) = submodule_filter {
5313                apply_submodule_filter(base, filter, &e)
5314            } else {
5315                Some(base)
5316            }
5317        })
5318        .collect();
5319
5320    Json(entries).into_response()
5321}
5322
5323// GET /api/metrics/submodules?root=<path>
5324// Returns the union of distinct submodule names found across all saved scan JSON artifacts
5325// for the given project root (or all roots if omitted).
5326#[derive(Deserialize)]
5327struct MetricsSubmodulesQuery {
5328    root: Option<String>,
5329}
5330
5331#[derive(Serialize)]
5332struct SubmoduleEntry {
5333    name: String,
5334    relative_path: String,
5335}
5336
5337async fn api_metrics_submodules_handler(
5338    State(state): State<AppState>,
5339    Query(query): Query<MetricsSubmodulesQuery>,
5340) -> Response {
5341    let json_paths: Vec<std::path::PathBuf> = {
5342        let reg = state.registry.lock().await;
5343        reg.entries
5344            .iter()
5345            .filter(|e| {
5346                query.root.as_ref().is_none_or(|root| {
5347                    let resolved = resolve_input_path(root);
5348                    let root_str = resolved.to_string_lossy().replace('\\', "/");
5349                    e.input_roots.iter().any(|r| r == &root_str)
5350                })
5351            })
5352            .filter_map(|e| e.json_path.clone())
5353            .collect()
5354    };
5355
5356    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5357    let mut result: Vec<SubmoduleEntry> = Vec::new();
5358
5359    for path in &json_paths {
5360        let Ok(json_str) = std::fs::read_to_string(path) else {
5361            continue;
5362        };
5363        let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
5364            continue;
5365        };
5366        for sub in &run.submodule_summaries {
5367            if seen.insert(sub.name.clone()) {
5368                result.push(SubmoduleEntry {
5369                    name: sub.name.clone(),
5370                    relative_path: sub.relative_path.clone(),
5371                });
5372            }
5373        }
5374    }
5375
5376    result.sort_by(|a, b| a.name.cmp(&b.name));
5377    Json(result).into_response()
5378}
5379
5380// ── CI ingest endpoint ────────────────────────────────────────────────────────
5381// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
5382// server stores and displays results without cloning or scanning anything itself.
5383//
5384// POST /api/ingest?label=<optional_display_name>
5385// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
5386// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
5387
5388#[derive(Deserialize)]
5389struct IngestQuery {
5390    label: Option<String>,
5391}
5392
5393#[derive(Serialize)]
5394struct IngestResponse {
5395    run_id: String,
5396    view_url: String,
5397}
5398
5399async fn api_ingest_handler(
5400    State(state): State<AppState>,
5401    Query(q): Query<IngestQuery>,
5402    Json(run): Json<sloc_core::AnalysisRun>,
5403) -> Response {
5404    let label = q.label.unwrap_or_else(|| {
5405        run.input_roots
5406            .first()
5407            .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
5408    });
5409
5410    let label_for_task = label.clone();
5411    let result = tokio::task::spawn_blocking(move || {
5412        let html = render_html(&run)?;
5413        let run_id = run.tool.run_id.clone();
5414        let run_id_safe = run_id.len() <= 128
5415            && !run_id.is_empty()
5416            && run_id
5417                .chars()
5418                .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
5419        if !run_id_safe {
5420            anyhow::bail!(
5421                "invalid run_id: must be 1–128 alphanumeric/dash/underscore/dot characters"
5422            );
5423        }
5424        let project_label = sanitize_project_label(&label_for_task);
5425        let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
5426        let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
5427            Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
5428            _ => project_label,
5429        };
5430        let (artifacts, _pending_pdf) = persist_run_artifacts(
5431            &run,
5432            &html,
5433            &output_dir,
5434            true,
5435            true,
5436            false,
5437            &label_for_task,
5438            &file_stem,
5439            RunResultContext::default(),
5440        )?;
5441        Ok::<_, anyhow::Error>((run_id, artifacts, run))
5442    })
5443    .await;
5444
5445    match result {
5446        Ok(Ok((run_id, artifacts, run))) => {
5447            register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
5448            (
5449                StatusCode::CREATED,
5450                Json(IngestResponse {
5451                    view_url: format!("/view-reports?run_id={run_id}"),
5452                    run_id,
5453                }),
5454            )
5455                .into_response()
5456        }
5457        Ok(Err(e)) => error::internal(&format!("{e:#}")),
5458        Err(e) => error::internal(&format!("{e}")),
5459    }
5460}
5461
5462// ── Trend report page ─────────────────────────────────────────────────────────
5463// Protected. Interactive time-series chart page that loads scan history via
5464// /api/metrics/history and renders a vanilla-SVG line chart.
5465//
5466// GET /trend-reports
5467
5468#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
5469async fn trend_report_handler(
5470    State(state): State<AppState>,
5471    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5472) -> Response {
5473    auto_scan_watched_dirs(&state).await;
5474
5475    let watched_dirs_list: Vec<String> = {
5476        let wd = state.watched_dirs.lock().await;
5477        wd.dirs.iter().map(|p| p.display().to_string()).collect()
5478    };
5479
5480    // Collect distinct project roots for the root selector dropdown.
5481    let roots: Vec<String> = {
5482        let reg = state.registry.lock().await;
5483        let mut seen = std::collections::BTreeSet::new();
5484        reg.entries
5485            .iter()
5486            .flat_map(|e| e.input_roots.iter().cloned())
5487            .filter(|r| seen.insert(r.clone()))
5488            .collect()
5489    };
5490
5491    let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
5492    let nonce = &csp_nonce;
5493    let version = env!("CARGO_PKG_VERSION");
5494
5495    // Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
5496    let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
5497        r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
5498            .to_string()
5499    } else {
5500        watched_dirs_list
5501            .iter()
5502            .fold(String::new(), |mut s, d| {
5503                use std::fmt::Write as _;
5504                let escaped = d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
5505                write!(
5506                    s,
5507                    r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
5508                ).expect("write to String is infallible");
5509                s
5510            })
5511    };
5512    let watched_dirs_html = format!(
5513        r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
5514    );
5515
5516    let html = format!(
5517        r##"<!doctype html>
5518<html lang="en">
5519<head>
5520  <meta charset="utf-8" />
5521  <meta name="viewport" content="width=device-width, initial-scale=1" />
5522  <title>OxideSLOC | Trend Reports</title>
5523  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
5524  <style nonce="{nonce}">
5525    :root {{
5526      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
5527      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
5528      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
5529      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
5530      --info-bg:#eef3ff; --info-text:#4467d8;
5531    }}
5532    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
5533    *{{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);}}
5534    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
5535    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
5536    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
5537    @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));}}}}
5538    .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);}}
5539    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
5540    .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));}}
5541    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
5542    .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;}}
5543    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
5544    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
5545    @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; }} }}
5546    .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;}}
5547    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
5548    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
5549    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
5550    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
5551    .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;}}
5552    .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;}}
5553    .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;}}
5554    .settings-modal{{position:fixed;z-index:9999;background:var(--surface);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;}}
5555    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
5556    .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);}}
5557    .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;}}
5558    .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;}}
5559    .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;}}
5560    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
5561    .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;}}
5562    .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);}}
5563    .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;}}
5564    .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;}}
5565    .tz-select:focus{{border-color:var(--oxide);}}
5566    .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
5567    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
5568    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
5569    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
5570    .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
5571    .trend-title-block{{flex:1;min-width:0;}}
5572    .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;}}
5573    .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
5574    .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;}}
5575    .chart-select:focus{{border-color:var(--accent);}}
5576    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
5577    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
5578    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
5579    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
5580    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
5581    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
5582    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
5583    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
5584    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
5585    .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;}}
5586    .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
5587    body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
5588    .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
5589    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
5590    .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;}}
5591    .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
5592    .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
5593    .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);}}
5594    .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
5595    .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;}}
5596    .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;}}
5597    .data-table tr:last-child td{{border-bottom:none;}}
5598    .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
5599    .num{{text-align:right;font-variant-numeric:tabular-nums;}}
5600    .table-wrap{{width:100%;overflow-x:auto;}}
5601    .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
5602    .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
5603    .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
5604    .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
5605    .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
5606    .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
5607    .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;}}
5608    .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;}}
5609    .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
5610    .pagination-info{{font-size:13px;color:var(--muted);}}
5611    .pagination-btns{{display:flex;gap:6px;}}
5612    .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;}}
5613    .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;}}
5614    #scan-history-table col:nth-child(1){{width:155px;}}
5615    #scan-history-table col:nth-child(2){{width:240px;}}
5616    #scan-history-table col:nth-child(3){{width:82px;}}
5617    #scan-history-table col:nth-child(4){{width:82px;}}
5618    #scan-history-table col:nth-child(5){{width:90px;}}
5619    #scan-history-table col:nth-child(6){{width:90px;}}
5620    #scan-history-table col:nth-child(7){{width:88px;}}
5621    #scan-history-table col:nth-child(8){{width:150px;}}
5622    #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
5623    .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;}}
5624    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 16px;flex-wrap:wrap;margin-bottom:16px;position:relative;z-index:1;}}
5625    .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
5626    .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
5627    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
5628    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
5629    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
5630    .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;}}
5631    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
5632    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
5633    .watched-chip-rm:hover{{color:var(--oxide);}}
5634    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
5635    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
5636    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
5637    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
5638    .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
5639    a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
5640    a.run-link:hover{{text-decoration:underline;}}
5641    .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);}}
5642    .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);}}
5643    body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
5644    .metric-num{{font-weight:700;color:var(--text);}}
5645    .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
5646    .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;}}
5647    .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
5648    .btn.primary:hover{{opacity:.9;}}
5649    .rpt-btn{{min-width:58px;justify-content:center;}}
5650    .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
5651    .report-cell{{overflow:visible!important;white-space:normal!important;}}
5652    .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
5653    .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
5654    .submod-details summary::-webkit-details-marker{{display:none;}}
5655    .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
5656    .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;}}
5657    .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
5658    body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
5659    .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
5660    .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;}}
5661    .export-btn:hover{{background:var(--line);}}
5662    .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
5663    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
5664    .site-footer a{{color:var(--muted);}}
5665    .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;}}
5666    .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;}}
5667    @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
5668  </style>
5669</head>
5670<body>
5671  <div class="background-watermarks" aria-hidden="true">
5672    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5673    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5674    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5675    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5676    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5677    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
5678  </div>
5679  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5680  <div class="top-nav">
5681    <div class="top-nav-inner">
5682      <a class="brand" href="/">
5683        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
5684        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
5685      </a>
5686      <div class="nav-right">
5687        <a class="nav-pill" href="/">Home</a>
5688        <div class="nav-dropdown">
5689          <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg></a>
5690          <div class="nav-dropdown-menu">
5691            <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>
5692          </div>
5693        </div>
5694        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
5695        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
5696        <div class="nav-dropdown">
5697          <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>
5698          <div class="nav-dropdown-menu">
5699            <a href="/webhook-setup"><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>
5700          </div>
5701        </div>
5702        <div class="server-status-wrap">
5703          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
5704          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
5705        </div>
5706        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5707          <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>
5708        </button>
5709        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
5710          <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>
5711          <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>
5712        </button>
5713      </div>
5714    </div>
5715  </div>
5716
5717  <div class="page">
5718    {watched_dirs_html}
5719    <div class="summary-strip" id="trend-stats"></div>
5720    <div class="panel">
5721      <div class="trend-header">
5722        <div class="trend-title-block">
5723          <h1>Trend Reports</h1>
5724          <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root, choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
5725          <span class="chart-hint-inline">
5726            <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>
5727            Click a dot or row to view its full report &nbsp;·&nbsp; <span class="dot" style="background:#C45C10;"></span>&thinsp;regular scan &nbsp;<span class="dot" style="background:#4472C4;"></span>&thinsp;tagged / release scan
5728          </span>
5729        </div>
5730        <div class="chart-actions">
5731          <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
5732            <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>
5733            Export Excel
5734          </button>
5735          <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
5736            <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>
5737            Export PNG
5738          </button>
5739        </div>
5740      </div>
5741
5742      <div class="controls-centered">
5743        <label>Project Root:
5744          <select class="chart-select" id="root-sel">
5745            <option value="">All projects</option>
5746          </select>
5747        </label>
5748        <label>Y Metric:
5749          <select class="chart-select" id="y-sel">
5750            <option value="code_lines">Code Lines</option>
5751            <option value="comment_lines">Comment Lines</option>
5752            <option value="blank_lines">Blank Lines</option>
5753            <option value="physical_lines">Physical Lines</option>
5754            <option value="files_analyzed">Files Analyzed</option>
5755          </select>
5756        </label>
5757        <label>X Axis:
5758          <select class="chart-select" id="x-sel">
5759            <option value="time">By Time</option>
5760            <option value="commit">By Commit</option>
5761            <option value="release">By Release</option>
5762            <option value="tag">Tagged Commits</option>
5763          </select>
5764        </label>
5765        <label id="submodule-label" style="display:none;">Submodule:
5766          <select class="chart-select" id="sub-sel">
5767            <option value="">All (project total)</option>
5768          </select>
5769        </label>
5770        <label>Chart Size:
5771          <select class="chart-select" id="scale-sel">
5772            <option value="0.75">Compact</option>
5773            <option value="1.2" selected>Normal</option>
5774            <option value="1.38">Large</option>
5775          </select>
5776        </label>
5777      </div>
5778
5779      <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
5780      <div id="data-table-wrap" style="overflow-x:auto;"></div>
5781    </div>
5782  </div>
5783
5784  <script nonce="{nonce}">
5785    (function() {{
5786      // Theme persistence
5787      var b = document.body;
5788      try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
5789      var tgl = document.getElementById('theme-toggle');
5790      if (tgl) tgl.addEventListener('click', function() {{
5791        var d = b.classList.toggle('dark-theme');
5792        try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
5793      }});
5794
5795      // Watermark randomizer
5796      (function() {{
5797        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
5798        if (!wms.length) return;
5799        var placed = [];
5800        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;}}
5801        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];}}
5802        var half=Math.floor(wms.length/2);
5803        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;}});
5804      }})();
5805
5806      // Code particles
5807      (function() {{
5808        var container = document.getElementById('code-particles');
5809        if (!container) return;
5810        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','​.rs .go .py','sloc_core','render_html','2,163 code'];
5811        for (var i = 0; i < 38; i++) {{
5812          (function(idx) {{
5813            var el = document.createElement('span');
5814            el.className = 'code-particle';
5815            el.textContent = snippets[idx % snippets.length];
5816            var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
5817            var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
5818            var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
5819            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';
5820            container.appendChild(el);
5821          }})(i);
5822        }}
5823      }})();
5824
5825      // Watched folder picker
5826      (function() {{
5827        var btn = document.getElementById('add-watched-btn');
5828        if (!btn) return;
5829        btn.addEventListener('click', function() {{
5830          fetch('/pick-directory?kind=reports')
5831            .then(function(r) {{ return r.json(); }})
5832            .then(function(data) {{
5833              if (!data.cancelled && data.selected_path) {{
5834                var form = document.createElement('form');
5835                form.method = 'POST';
5836                form.action = '/watched-dirs/add';
5837                var ri = document.createElement('input');
5838                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
5839                var fi = document.createElement('input');
5840                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
5841                form.appendChild(ri); form.appendChild(fi);
5842                document.body.appendChild(form);
5843                form.submit();
5844              }}
5845            }})
5846            .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
5847        }});
5848      }})();
5849
5850      // Settings / color-scheme modal
5851      (function() {{
5852        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'}}];
5853        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);}});}}
5854        try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
5855        var btn=document.getElementById('settings-btn');if(!btn)return;
5856        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
5857        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
5858        document.body.appendChild(m);
5859        var g=document.getElementById('scheme-grid');
5860        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);}});
5861        var cl=document.getElementById('settings-close');
5862        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.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};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);}});}};var tzSel=document.getElementById('tz-select');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);
5863        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');}});
5864        if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
5865        document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
5866      }})();
5867    }})();
5868
5869    var ROOTS = {roots_json};
5870    var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
5871    var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
5872    var allData = [];
5873
5874    // Populate root selector
5875    var rootSel = document.getElementById('root-sel');
5876    ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
5877
5878    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();}}
5879    function fmtFull(n){{return Number(n).toLocaleString();}}
5880    function esc(s){{ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }}
5881
5882    // Tooltip
5883    var tt = document.createElement('div');
5884    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:9999;max-width:280px;color:var(--text);';
5885    document.body.appendChild(tt);
5886    function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
5887    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';}}
5888    function hideTT(){{tt.style.display='none';}}
5889
5890    function statExact(compact, full){{
5891      return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
5892    }}
5893    function statVal(n){{
5894      var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
5895    }}
5896
5897    function updateStats(data){{
5898      var statsEl=document.getElementById('trend-stats');
5899      if(!statsEl)return;
5900      if(!data||!data.length){{statsEl.innerHTML='';return;}}
5901      var yKey=document.getElementById('y-sel').value;
5902      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
5903      var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
5904      var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
5905      var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
5906      var absDelta=Math.abs(delta);
5907      var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
5908      var deltaExact=statExact(deltaCompact,deltaFull);
5909      var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
5910      statsEl.innerHTML=
5911        '<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>'+
5912        '<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>'+
5913        '<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>'+
5914        '<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>';
5915    }}
5916
5917    var subSel = document.getElementById('sub-sel');
5918    var subLabel = document.getElementById('submodule-label');
5919
5920    function populateSubmodules(root){{
5921      if(!subSel||!subLabel)return;
5922      while(subSel.options.length>1)subSel.remove(1);
5923      subSel.value='';
5924      var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
5925      fetch(url)
5926        .then(function(r){{return r.json();}})
5927        .then(function(subs){{
5928          if(!subs||!subs.length){{subLabel.style.display='none';return;}}
5929          subs.forEach(function(s){{
5930            var o=document.createElement('option');
5931            o.value=s.name;
5932            o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
5933            subSel.appendChild(o);
5934          }});
5935          subLabel.style.display='';
5936        }})
5937        .catch(function(){{subLabel.style.display='none';}});
5938    }}
5939
5940    var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div>';
5941
5942    function loadAndRender(){{
5943      var root = rootSel.value;
5944      var sub = subSel ? subSel.value : '';
5945      document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
5946      document.getElementById('data-table-wrap').innerHTML='';
5947      var url = '/api/metrics/history?limit=100'
5948        + (root ? '&root='+encodeURIComponent(root) : '')
5949        + (sub  ? '&submodule='+encodeURIComponent(sub) : '');
5950      fetch(url).then(function(r){{return r.json();}}).then(function(data){{
5951        allData = data;
5952        render(data);
5953        updateStats(data);
5954      }}).catch(function(){{
5955        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>';
5956      }});
5957    }}
5958
5959    function render(data){{
5960      var yKey = document.getElementById('y-sel').value;
5961      var xMode = document.getElementById('x-sel').value;
5962
5963      // Filter for tag/release mode
5964      var pts = data;
5965      if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
5966
5967      // Sort oldest-first for the line chart
5968      pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
5969
5970      var wrap = document.getElementById('chart-wrap');
5971      if(!pts.length){{
5972        var emptyMsg = (xMode === 'tag')
5973          ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
5974          : 'No scan data found for the selected filters.';
5975        wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
5976        renderTable([]);
5977        return;
5978      }}
5979
5980      var scaleEl=document.getElementById('scale-sel');
5981      var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
5982      var W=Math.round(900*sc),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;
5983      var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
5984
5985      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
5986
5987      var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
5988      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>';
5989
5990      var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
5991
5992      // Grid + Y axis ticks
5993      for(var ti=0;ti<=5;ti++){{
5994        var gy=PT+CH-Math.round(ti/5*CH);
5995        var gv=Math.round(ti/5*maxY);
5996        svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
5997        svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
5998      }}
5999
6000      // X axis labels (every N-th point to avoid crowding)
6001      var labelEvery=Math.max(1,Math.ceil(pts.length/10));
6002      pts.forEach(function(d,i){{
6003        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6004        if(i%labelEvery===0||i===pts.length-1){{
6005          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)));
6006          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>';
6007        }}
6008      }});
6009
6010      // Axis label
6011      var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
6012      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>';
6013      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>';
6014
6015      // Area fill + line path
6016      var pathD='';
6017      pts.forEach(function(d,i){{
6018        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6019        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6020        pathD+=(i===0?'M':'L')+x+','+y;
6021      }});
6022      if(pts.length>1){{
6023        var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
6024        svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
6025      }}
6026      svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
6027
6028      // Data points (clickable) + permanent value labels
6029      var showLabels = pts.length <= 40;
6030      var labelEveryN = pts.length > 20 ? 2 : 1;
6031      pts.forEach(function(d,i){{
6032        var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
6033        var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
6034        var hasTags=d.tags&&d.tags.length>0;
6035        var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
6036        var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
6037        svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
6038        if(showLabels && i%labelEveryN===0){{
6039          var lx=x, ly=y-r-5;
6040          svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmt(Number(d[yKey]))+'</text>';
6041        }}
6042      }});
6043
6044      svg+='</svg>';
6045      wrap.innerHTML=svg;
6046
6047      // Attach point tooltips
6048      wrap.querySelectorAll('.trend-pt').forEach(function(c){{
6049        c.addEventListener('mouseover',function(e){{
6050          var d=pts[parseInt(this.dataset.idx)];
6051          var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
6052          var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
6053          showTT(e,
6054            '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
6055            (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
6056            'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
6057            (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
6058          );
6059          this.setAttribute('r','8');
6060        }});
6061        c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
6062        c.addEventListener('mousemove',moveTT);
6063        c.addEventListener('click',function(){{
6064          var d=pts[parseInt(this.dataset.idx)];
6065          if(d.html_url) window.open(d.html_url,'_blank');
6066        }});
6067      }});
6068
6069      renderTable(pts, yKey);
6070    }}
6071
6072    var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
6073    var shProjFilter='', shBranchFilter='';
6074
6075    function fmtPST(isoStr){{
6076      if(!isoStr)return'';
6077      var d=new Date(isoStr);
6078      if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
6079      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);}}
6080      function p(n){{return n<10?'0'+n:String(n);}}
6081      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++;}}}}
6082      var yr=d.getUTCFullYear();
6083      var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
6084      var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
6085      var isDST=d>=dstStart&&d<dstEnd;
6086      var off=isDST?-7*3600*1000:-8*3600*1000;
6087      var lbl=isDST?'PDT':'PST';
6088      var loc=new Date(d.getTime()+off);
6089      return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
6090    }}
6091
6092    function getShRows(){{
6093      var proj=shProjFilter.toLowerCase().trim();
6094      var branch=shBranchFilter;
6095      return shData.filter(function(d){{
6096        if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
6097        if(branch&&(d.branch||'')!==branch)return false;
6098        return true;
6099      }});
6100    }}
6101
6102    function renderShPage(){{
6103      var filtered=getShRows();
6104      if(shSortCol){{
6105        filtered.sort(function(a,b){{
6106          var va,vb;
6107          if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
6108          if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
6109          else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
6110          else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
6111          else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
6112          return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
6113        }});
6114      }}
6115      var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
6116      shPage=Math.min(shPage,totalPages);
6117      var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
6118      var visible=filtered.slice(start,end);
6119      var tbody=document.getElementById('sh-tbody');
6120      if(!tbody)return;
6121      tbody.innerHTML=visible.map(function(d){{
6122        var tsHtml=esc(fmtPST(d.timestamp));
6123        var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">&#8212;</span>';
6124        var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
6125        var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">&#8212;</span>';
6126        var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'&#8212;';
6127        var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
6128        var reportCell='';
6129        if(d.html_url){{
6130          reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
6131          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>';}}
6132          reportCell+='</div>';
6133        }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">&#8212;</span>';}}
6134        if(d.submodule_links&&d.submodule_links.length){{
6135          reportCell+='<details class="submod-details"><summary>&#8627; '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
6136          d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
6137          reportCell+='</div></details>';
6138        }}
6139        return '<tr>'
6140          +'<td>'+tsHtml+'</td>'
6141          +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
6142          +'<td>'+runIdHtml+'</td>'
6143          +'<td>'+commitHtml+'</td>'
6144          +'<td>'+branchHtml+'</td>'
6145          +'<td>'+tags+'</td>'
6146          +'<td class="num">'+metricHtml+'</td>'
6147          +'<td class="report-cell">'+reportCell+'</td>'
6148          +'</tr>';
6149      }}).join('');
6150      var pgRange=document.getElementById('sh-pg-range');
6151      if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'–'+end+' of '+total:'No results';
6152      var pgInfo=document.getElementById('sh-pg-info');
6153      if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
6154      var pgBtns=document.getElementById('sh-pg-btns');
6155      if(pgBtns){{
6156        pgBtns.innerHTML='';
6157        function mkPgBtn(lbl,pg,active,disabled){{
6158          var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
6159          if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
6160          return b;
6161        }}
6162        pgBtns.appendChild(mkPgBtn('‹',shPage-1,false,shPage===1));
6163        var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
6164        for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
6165        pgBtns.appendChild(mkPgBtn('›',shPage+1,false,shPage===totalPages));
6166      }}
6167    }}
6168
6169    function wireTableBehavior(){{
6170      var pf=document.getElementById('sh-proj-filter');
6171      if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
6172      var bf=document.getElementById('sh-branch-filter');
6173      if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
6174      var rb=document.getElementById('sh-reset-btn');
6175      if(rb)rb.addEventListener('click',function(){{
6176        shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
6177        var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
6178        var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
6179        document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6180        renderShPage();
6181      }});
6182      var pps=document.getElementById('sh-per-page');
6183      if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
6184      var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
6185      ths.forEach(function(th){{
6186        th.addEventListener('click',function(e){{
6187          if(e.target.classList.contains('col-resize-handle'))return;
6188          var col=th.dataset.col;
6189          if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
6190          ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='↕';t.classList.remove('sort-asc','sort-desc');}});
6191          th.classList.add('sort-'+shSortOrder);
6192          var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
6193          shPage=1;renderShPage();
6194        }});
6195      }});
6196      var table=document.getElementById('scan-history-table');
6197      if(!table)return;
6198      var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
6199      var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
6200      allThs.forEach(function(th,i){{
6201        var handle=th.querySelector('.col-resize-handle');
6202        if(!handle||!cols[i])return;
6203        var startX,startW;
6204        handle.addEventListener('mousedown',function(e){{
6205          e.stopPropagation();e.preventDefault();
6206          startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
6207          handle.classList.add('dragging');
6208          function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
6209          function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
6210          document.addEventListener('mousemove',onMove);
6211          document.addEventListener('mouseup',onUp);
6212        }});
6213      }});
6214    }}
6215
6216    function renderTable(pts, yKey){{
6217      var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
6218      var wrap=document.getElementById('data-table-wrap');
6219      if(!pts||!pts.length){{wrap.innerHTML='';return;}}
6220      var yLabel=Y_LABELS[yKey]||yKey||'';
6221      shData=pts.slice().reverse();
6222      shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
6223      shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
6224      var branches={{}};
6225      shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
6226      var branchOpts='<option value="">All branches</option>';
6227      Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
6228      wrap.innerHTML=
6229        '<div class="chart-section-header">SCAN HISTORY</div>'+
6230        '<div class="filter-row">'+
6231          '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by project…">'+
6232          '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
6233          '<button type="button" class="btn" id="sh-reset-btn">↻ Reset view</button>'+
6234        '</div>'+
6235        '<div class="table-wrap">'+
6236        '<table id="scan-history-table" class="data-table">'+
6237        '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
6238        '<thead><tr id="sh-thead">'+
6239        '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6240        '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6241        '<th>Run ID<div class="col-resize-handle"></div></th>'+
6242        '<th>Commit<div class="col-resize-handle"></div></th>'+
6243        '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">&#8597;</span><div class="col-resize-handle"></div></th>'+
6244        '<th>Tags<div class="col-resize-handle"></div></th>'+
6245        '<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>'+
6246        '<th>Report<div class="col-resize-handle"></div></th>'+
6247        '</tr></thead>'+
6248        '<tbody id="sh-tbody"></tbody>'+
6249        '</table>'+
6250        '</div>'+
6251        '<div class="pagination">'+
6252          '<span class="pagination-info" id="sh-pg-info"></span>'+
6253          '<div class="pagination-btns" id="sh-pg-btns"></div>'+
6254          '<div style="display:flex;align-items:center;gap:8px;">'+
6255            '<span style="font-size:13px;color:var(--muted);">Show</span>'+
6256            '<select class="filter-select" id="sh-per-page">'+
6257              '<option value="10">10 per page</option>'+
6258              '<option value="25" selected>25 per page</option>'+
6259              '<option value="50">50 per page</option>'+
6260              '<option value="100">100 per page</option>'+
6261            '</select>'+
6262            '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
6263          '</div>'+
6264        '</div>';
6265      wireTableBehavior();
6266      renderShPage();
6267    }}
6268
6269    function exportXLSX(){{
6270      if(!allData||!allData.length){{alert('No data to export yet.');return;}}
6271      var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
6272      var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
6273      var s1R=sorted.map(function(d){{
6274        return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',d.commit||'',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||''];
6275      }});
6276      var pm={{}};
6277      sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
6278      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'];
6279      var s2R=Object.keys(pm).map(function(p){{
6280        var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
6281        var lat=sc[sc.length-1],fst=sc[0];
6282        var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
6283        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);
6284        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];
6285      }});
6286      var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
6287      var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
6288      a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
6289      a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
6290    }}
6291
6292    function buildXLSX(sheets,chartRows,chartRows2){{
6293      function s2b(s){{return new TextEncoder().encode(s);}}
6294      function xe(s){{return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}}
6295      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;}}
6296      function crc32(d){{
6297        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;}}}}
6298        var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
6299      }}
6300      function buildSheet(hdr,rows,drawRid,withCtrl){{
6301        var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
6302        if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
6303        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
6304        x+='<row r="1">';
6305        hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
6306        if(withCtrl){{x+='<c r="M1" t="inlineStr" s="1"><is><t>&#8595; Metric Selector</t></is></c><c r="N1" t="inlineStr"><is><t>Code Lines</t></is></c>';}}
6307        x+='</row>';
6308        rows.forEach(function(row,ri){{
6309          var rn=ri+2;
6310          x+='<row r="'+rn+'">';
6311          row.forEach(function(cell,ci){{
6312            var addr=col2l(ci+1)+rn;
6313            if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
6314            else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
6315          }});
6316          if(withCtrl){{x+='<c r="M'+rn+'"><f>CHOOSE(MATCH($N$1,{{"Code Lines","Comment Lines","Blank Lines","Physical Lines"}},0),F'+rn+',G'+rn+',H'+rn+',I'+rn+')</f><v>'+Number(row[5])+'</v></c>';}}
6317          x+='</row>';
6318        }});
6319        x+='</sheetData>';
6320        if(withCtrl){{x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="N1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines"</formula1></dataValidation></dataValidations>';}}
6321        if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
6322        return x+'</worksheet>';
6323      }}
6324      function buildChartXML(rows){{
6325        var sn="'Scan History'";
6326        var nr=rows.length,er=nr+1;
6327        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'}}];
6328        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6329        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">';
6330        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6331        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6332        sd.forEach(function(s,i){{
6333          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6334          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>';
6335          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6336          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>';
6337          var dlp=(i===2)?'b':'t';
6338          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="'+dlp+'"/></c:dLbls>';
6339          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6340          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6341          x+='</c:strCache></c:strRef></c:cat>';
6342          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+'"/>';
6343          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6344          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6345        }});
6346        x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
6347        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>';
6348        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>';
6349        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6350        return x;
6351      }}
6352      function buildChartXML2(rows){{
6353        var sn="'By Project'";
6354        var nr=rows.length,er=nr+1;
6355        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'}}];
6356        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6357        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">';
6358        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
6359        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6360        sd.forEach(function(s,i){{
6361          x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
6362          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>';
6363          x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
6364          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>';
6365          var dlp=(i===2)?'b':'t';
6366          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="'+dlp+'"/></c:dLbls>';
6367          x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6368          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6369          x+='</c:strCache></c:strRef></c:cat>';
6370          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+'"/>';
6371          rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
6372          x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6373        }});
6374        x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
6375        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>';
6376        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>';
6377        x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
6378        return x;
6379      }}
6380      function buildChartXML3(rows){{
6381        var sn="'Scan History'";
6382        var nr=rows.length,er=nr+1;
6383        var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6384        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">';
6385        x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
6386        x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
6387        x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
6388        x+='<c:tx><c:strRef><c:f>'+sn+'!$N$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>';
6389        x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
6390        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>';
6391        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>';
6392        x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
6393        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
6394        x+='</c:strCache></c:strRef></c:cat>';
6395        x+='<c:val><c:numRef><c:f>'+sn+'!$M$2:$M$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
6396        rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
6397        x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
6398        x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
6399        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>';
6400        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>';
6401        x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Focus View — change N1 to switch metric</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>';
6402        return x;
6403      }}
6404      var hasChart=!!(chartRows&&chartRows.length);
6405      var nr=hasChart?chartRows.length:0;
6406      var hasChart2=!!(chartRows2&&chartRows2.length);
6407      var nr2=hasChart2?chartRows2.length:0;
6408      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>';
6409      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"/>';
6410      sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
6411      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"/>';}}
6412      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"/>';}}
6413      ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
6414      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>';
6415      var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
6416      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"/>';}});
6417      wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
6418      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>';
6419      sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
6420      wbx+='</sheets></workbook>';
6421      var files=[
6422        {{name:'[Content_Types].xml',data:s2b(ct)}},
6423        {{name:'_rels/.rels',data:s2b(dotrels)}},
6424        {{name:'xl/workbook.xml',data:s2b(wbx)}},
6425        {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
6426        {{name:'xl/styles.xml',data:s2b(styl)}}
6427      ];
6428      // Chart embedded directly in Scan History (sheet1); By Project is plain
6429      sheets.forEach(function(s,i){{
6430        files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0)))}});
6431      }});
6432      if(hasChart){{
6433        var fromRow=nr+4,toRow=nr+24;
6434        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>')}});
6435        var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6436        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">';
6437        drx+='<xdr:twoCellAnchor editAs="twoCell">';
6438        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>';
6439        drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
6440        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6441        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6442        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6443        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6444        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
6445        var focRow=toRow+2,focRowEnd=toRow+22;
6446        drx+='<xdr:twoCellAnchor editAs="twoCell">';
6447        drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
6448        drx+='<xdr:to><xdr:col>10</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+focRowEnd+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
6449        drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6450        drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6451        drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6452        drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
6453        drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
6454        files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
6455        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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
6456        files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
6457        files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
6458      }}
6459      if(hasChart2){{
6460        var fromRow2=nr2+4,toRow2=nr2+24;
6461        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>')}});
6462        var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
6463        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">';
6464        drx2+='<xdr:twoCellAnchor editAs="twoCell">';
6465        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>';
6466        drx2+='<xdr:to><xdr:col>11</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
6467        drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
6468        drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
6469        drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
6470        drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
6471        drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
6472        files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
6473        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>')}});
6474        files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
6475      }}
6476      var parts=[],offsets=[],total=0;
6477      files.forEach(function(f){{
6478        offsets.push(total);
6479        var nb=s2b(f.name),crc=crc32(f.data);
6480        var h=new DataView(new ArrayBuffer(30+nb.length));
6481        h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
6482        h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
6483        h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
6484        h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
6485        for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
6486        parts.push(new Uint8Array(h.buffer));parts.push(f.data);
6487        total+=30+nb.length+f.data.length;
6488      }});
6489      var cdStart=total;
6490      files.forEach(function(f,fi){{
6491        var nb=s2b(f.name),crc=crc32(f.data);
6492        var cd=new DataView(new ArrayBuffer(46+nb.length));
6493        cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
6494        cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
6495        cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
6496        cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
6497        cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
6498        for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
6499        parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
6500      }});
6501      var cdSz=total-cdStart;
6502      var eocd=new DataView(new ArrayBuffer(22));
6503      eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
6504      eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
6505      eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
6506      parts.push(new Uint8Array(eocd.buffer));
6507      var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
6508      var out=new Uint8Array(sz);var off=0;
6509      parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
6510      return out.buffer;
6511    }}
6512
6513    function exportPNG(){{
6514      var svgEl=document.querySelector('#chart-wrap svg');
6515      if(!svgEl){{alert('No chart to export yet.');return;}}
6516      var svgStr=new XMLSerializer().serializeToString(svgEl);
6517      var vb=svgEl.viewBox.baseVal,scale=2;
6518      var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
6519      var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
6520      var url=URL.createObjectURL(blob);
6521      var img=new Image();
6522      img.onload=function(){{
6523        var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
6524        var ctx=canvas.getContext('2d');
6525        var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
6526        ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
6527        ctx.scale(scale,scale);ctx.drawImage(img,0,0);
6528        URL.revokeObjectURL(url);
6529        var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
6530      }};
6531      img.src=url;
6532    }}
6533
6534    ['y-sel','x-sel','scale-sel'].forEach(function(id){{
6535      var el=document.getElementById(id);
6536      if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
6537    }});
6538    rootSel.addEventListener('change',function(){{
6539      populateSubmodules(rootSel.value);
6540      loadAndRender();
6541    }});
6542    if(subSel)subSel.addEventListener('change',loadAndRender);
6543
6544    var xlsxBtn=document.getElementById('export-xlsx-btn');
6545    if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
6546    var pngBtn=document.getElementById('export-png-btn');
6547    if(pngBtn)pngBtn.addEventListener('click',exportPNG);
6548
6549    populateSubmodules(rootSel.value);
6550    loadAndRender();
6551
6552    (function randomizeWatermarks() {{
6553      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6554      if (!wms.length) return;
6555      var placed = [];
6556      function tooClose(top, left) {{
6557        for (var i = 0; i < placed.length; i++) {{
6558          var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
6559          if (dt < 16 && dl < 12) return true;
6560        }}
6561        return false;
6562      }}
6563      function pick(leftBand) {{
6564        for (var attempt = 0; attempt < 50; attempt++) {{
6565          var top = Math.random() * 88 + 2;
6566          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6567          if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
6568        }}
6569        var top = Math.random() * 88 + 2;
6570        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
6571        placed.push([top, left]); return [top, left];
6572      }}
6573      var half = Math.floor(wms.length / 2);
6574      wms.forEach(function (img, i) {{
6575        var pos = pick(i < half);
6576        var size = Math.floor(Math.random() * 100 + 120);
6577        var rot = (Math.random() * 360).toFixed(1);
6578        var op = (Math.random() * 0.08 + 0.12).toFixed(2);
6579        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;
6580      }});
6581    }})();
6582    (function spawnCodeParticles() {{
6583      var container = document.getElementById('code-particles');
6584      if (!container) return;
6585      var snippets = [
6586        '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
6587        '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
6588        'git main','#[derive]','impl Scan','3,841 physical','files: 60',
6589        '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
6590        'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
6591      ];
6592      var count = 38;
6593      for (var i = 0; i < count; i++) {{
6594        (function(idx) {{
6595          var el = document.createElement('span');
6596          el.className = 'code-particle';
6597          el.textContent = snippets[idx % snippets.length];
6598          var left = Math.random() * 94 + 2;
6599          var top = Math.random() * 88 + 6;
6600          var dur = (Math.random() * 10 + 9).toFixed(1);
6601          var delay = (Math.random() * 18).toFixed(1);
6602          var rot = (Math.random() * 26 - 13).toFixed(1);
6603          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6604          el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
6605          container.appendChild(el);
6606        }})(i);
6607      }}
6608    }})();
6609  </script>
6610  <footer class="site-footer">
6611    oxide-sloc v{version} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
6612    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
6613    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
6614    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
6615    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
6616  </footer>
6617</body>
6618</html>"##,
6619    );
6620
6621    Html(html).into_response()
6622}
6623
6624fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
6625    use std::collections::HashMap;
6626    if !per_file_records.iter().any(|f| f.coverage.is_some()) {
6627        return vec![];
6628    }
6629    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6630    for rec in per_file_records {
6631        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6632            let e = totals.entry(lang.display_name().to_string()).or_default();
6633            e.0 += u64::from(cov.lines_found);
6634            e.1 += u64::from(cov.lines_hit);
6635        }
6636    }
6637    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
6638    let mut pairs: Vec<(String, f64)> = totals
6639        .into_iter()
6640        .filter(|(_, (found, _))| *found > 0)
6641        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6642        .collect();
6643    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6644    pairs
6645        .iter()
6646        .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
6647        .collect()
6648}
6649
6650fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
6651    let mut high = 0u64;
6652    let mut mid = 0u64;
6653    let mut low = 0u64;
6654    for rec in per_file_records {
6655        if let Some(cov) = &rec.coverage {
6656            if cov.lines_found == 0 {
6657                continue;
6658            }
6659            let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
6660            if pct >= 80.0 {
6661                high += 1;
6662            } else if pct >= 50.0 {
6663                mid += 1;
6664            } else {
6665                low += 1;
6666            }
6667        }
6668    }
6669    (high, mid, low)
6670}
6671
6672fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
6673    let mut arr: Vec<serde_json::Value> = per_file_records
6674        .iter()
6675        .filter_map(|rec| {
6676            rec.coverage.as_ref().map(|cov| {
6677                let line_pct = if cov.lines_found > 0 {
6678                    (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
6679                        / 10.0
6680                } else {
6681                    0.0
6682                };
6683                let fn_pct = if cov.functions_found > 0 {
6684                    (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
6685                        .round()
6686                        / 10.0
6687                } else {
6688                    -1.0
6689                };
6690                serde_json::json!({
6691                    "rel": rec.relative_path,
6692                    "lang": rec.language.map_or("?", |l| l.display_name()),
6693                    "line_pct": line_pct,
6694                    "fn_pct": fn_pct,
6695                    "lhit": cov.lines_hit,
6696                    "lfound": cov.lines_found,
6697                    "fhit": cov.functions_hit,
6698                    "ffound": cov.functions_found,
6699                })
6700            })
6701        })
6702        .collect();
6703    arr.sort_by(|a, b| {
6704        let pa = a["line_pct"].as_f64().unwrap_or(0.0);
6705        let pb = b["line_pct"].as_f64().unwrap_or(0.0);
6706        pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
6707    });
6708    arr
6709}
6710
6711#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6712fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
6713    let mut langs: Vec<&sloc_core::LanguageSummary> = run
6714        .totals_by_language
6715        .iter()
6716        .filter(|l| l.test_count > 0)
6717        .collect();
6718    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6719    let lang_tests: Vec<serde_json::Value> = langs
6720        .iter()
6721        .map(|l| {
6722            let d = if l.code_lines > 0 {
6723                l.test_count as f64 / l.code_lines as f64 * 1000.0
6724            } else {
6725                0.0
6726            };
6727            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6728                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6729                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6730        })
6731        .collect();
6732    let cov_arr = compute_cov_pct_arr(&run.per_file_records);
6733    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
6734    let t = &run.summary_totals;
6735    let total_tests = t.test_count;
6736    let density = if t.code_lines > 0 {
6737        total_tests as f64 / t.code_lines as f64 * 1000.0
6738    } else {
6739        0.0
6740    };
6741    let most_tested = langs.first().map_or_else(
6742        || "\u{2014}".to_string(),
6743        |l| l.language.display_name().to_string(),
6744    );
6745    let test_files: u64 = run
6746        .per_file_records
6747        .iter()
6748        .filter(|f| f.raw_line_categories.test_count > 0)
6749        .count() as u64;
6750    let cov_line = if t.coverage_lines_found > 0 {
6751        format!(
6752            "{:.1}",
6753            t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
6754        )
6755    } else {
6756        "0".to_string()
6757    };
6758    let cov_fn = if t.coverage_functions_found > 0 {
6759        format!(
6760            "{:.1}",
6761            t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
6762        )
6763    } else {
6764        "0".to_string()
6765    };
6766    let cov_branch = if t.coverage_branches_found > 0 {
6767        format!(
6768            "{:.1}",
6769            t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
6770        )
6771    } else {
6772        "0".to_string()
6773    };
6774    let has_cov = !cov_arr.is_empty();
6775    let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
6776    serde_json::json!({
6777        "totals": {
6778            "test_count": total_tests,
6779            "assertions": t.test_assertion_count,
6780            "suites": t.test_suite_count,
6781            "test_files": test_files,
6782            "total_files": t.files_analyzed,
6783            "density_str": format!("{density:.1}"),
6784            "most_tested": most_tested,
6785            "langs_with_tests": langs.len(),
6786            "cov_line": cov_line,
6787            "cov_fn": cov_fn,
6788            "cov_branch": cov_branch,
6789        },
6790        "lang_tests": lang_tests,
6791        "cov": cov_arr,
6792        "cov_tiers": {"high": high, "mid": mid, "low": low},
6793        "file_cov": file_cov_arr,
6794        "has_coverage": has_cov,
6795        "submodules": {},
6796    })
6797}
6798
6799#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6800fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
6801    let mut langs: Vec<&sloc_core::LanguageSummary> = sub
6802        .language_summaries
6803        .iter()
6804        .filter(|l| l.test_count > 0)
6805        .collect();
6806    langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6807    let lang_tests: Vec<serde_json::Value> = langs
6808        .iter()
6809        .map(|l| {
6810            let d = if l.code_lines > 0 {
6811                l.test_count as f64 / l.code_lines as f64 * 1000.0
6812            } else {
6813                0.0
6814            };
6815            serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
6816                "assertions": l.test_assertion_count, "suites": l.test_suite_count,
6817                "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
6818        })
6819        .collect();
6820    let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
6821    let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
6822    let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
6823    let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
6824    let density = if sub.code_lines > 0 {
6825        total_tests as f64 / sub.code_lines as f64 * 1000.0
6826    } else {
6827        0.0
6828    };
6829    let most_tested = langs.first().map_or_else(
6830        || "\u{2014}".to_string(),
6831        |l| l.language.display_name().to_string(),
6832    );
6833    serde_json::json!({
6834        "totals": {
6835            "test_count": total_tests,
6836            "assertions": total_assertions,
6837            "suites": total_suites,
6838            "test_files": test_files_approx,
6839            "total_files": sub.files_analyzed,
6840            "density_str": format!("{density:.1}"),
6841            "most_tested": most_tested,
6842            "langs_with_tests": langs.len(),
6843            "cov_line": "0",
6844            "cov_fn": "0",
6845            "cov_branch": "0",
6846        },
6847        "lang_tests": lang_tests,
6848        "cov": [],
6849        "cov_tiers": {"high": 0, "mid": 0, "low": 0},
6850        "has_coverage": false,
6851    })
6852}
6853
6854fn compute_cov_json_str(run: &AnalysisRun) -> String {
6855    use std::collections::HashMap;
6856    let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
6857    for rec in &run.per_file_records {
6858        if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
6859            let e = totals.entry(lang.display_name().to_string()).or_default();
6860            e.0 += u64::from(cov.lines_found);
6861            e.1 += u64::from(cov.lines_hit);
6862        }
6863    }
6864    #[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
6865    let mut pairs: Vec<(String, f64)> = totals
6866        .into_iter()
6867        .filter(|(_, (found, _))| *found > 0)
6868        .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
6869        .collect();
6870    pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
6871    let parts: Vec<String> = pairs
6872        .iter()
6873        .map(|(lang, pct)| {
6874            let name = lang.replace('"', "\\\"");
6875            format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
6876        })
6877        .collect();
6878    format!("[{}]", parts.join(","))
6879}
6880
6881fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
6882    let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
6883    format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
6884}
6885
6886fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
6887    let mut entry = build_test_scope_entry(run);
6888    if !run.submodule_summaries.is_empty() {
6889        let subs: serde_json::Map<String, serde_json::Value> = run
6890            .submodule_summaries
6891            .iter()
6892            .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
6893            .collect();
6894        entry["submodules"] = serde_json::Value::Object(subs);
6895    }
6896    entry
6897}
6898
6899// GET /test-metrics
6900#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
6901#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
6902async fn test_metrics_handler(
6903    State(state): State<AppState>,
6904    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6905) -> Response {
6906    auto_scan_watched_dirs(&state).await;
6907    let watched_dirs_list: Vec<String> = {
6908        let wd = state.watched_dirs.lock().await;
6909        wd.dirs.iter().map(|p| p.display().to_string()).collect()
6910    };
6911    let latest_run: Option<AnalysisRun> = {
6912        let reg = state.registry.lock().await;
6913        let json_str: Option<String> = reg
6914            .entries
6915            .first()
6916            .and_then(|e| e.json_path.as_ref())
6917            .and_then(|p| std::fs::read_to_string(p).ok());
6918        drop(reg);
6919        json_str
6920            .as_deref()
6921            .and_then(|s| serde_json::from_str(s).ok())
6922    };
6923
6924    // Build per-language chart JSON (kept for has_coverage derivation via cov_json).
6925    let _lang_tests_json: String = latest_run.as_ref().map_or_else(
6926        || "[]".to_string(),
6927        |r| {
6928            let mut langs: Vec<&sloc_core::LanguageSummary> = r
6929                .totals_by_language
6930                .iter()
6931                .filter(|l| l.test_count > 0)
6932                .collect();
6933            langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
6934            let parts: Vec<String> = langs
6935                .iter()
6936                .map(|l| {
6937                    let name = l.language.display_name().replace('"', "\\\"");
6938                    let density = if l.code_lines > 0 {
6939                        // ratio for density display, precision loss acceptable
6940                        #[allow(clippy::cast_precision_loss)]
6941                        { l.test_count as f64 / l.code_lines as f64 * 1000.0 }
6942                    } else {
6943                        0.0
6944                    };
6945                    format!(
6946                        r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
6947                        name = name,
6948                        t = l.test_count,
6949                        a = l.test_assertion_count,
6950                        s = l.test_suite_count,
6951                        c = l.code_lines,
6952                        d = density,
6953                        f = l.files,
6954                    )
6955                })
6956                .collect();
6957            format!("[{}]", parts.join(","))
6958        },
6959    );
6960
6961    // Build coverage chart JSON (per-language avg line coverage %).
6962    let cov_json: String = latest_run
6963        .as_ref()
6964        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
6965        .map_or_else(|| "[]".to_string(), compute_cov_json_str);
6966
6967    // Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
6968    let _cov_tier_json: String = latest_run
6969        .as_ref()
6970        .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
6971        .map_or_else(
6972            || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
6973            compute_cov_tier_json_str,
6974        );
6975
6976    let total_tests: u64 = latest_run
6977        .as_ref()
6978        .map_or(0, |r| r.summary_totals.test_count);
6979    let total_assertions: u64 = latest_run
6980        .as_ref()
6981        .map_or(0, |r| r.summary_totals.test_assertion_count);
6982    let total_suites: u64 = latest_run
6983        .as_ref()
6984        .map_or(0, |r| r.summary_totals.test_suite_count);
6985    let total_code: u64 = latest_run
6986        .as_ref()
6987        .map_or(0, |r| r.summary_totals.code_lines);
6988    let workspace_density: f64 = if total_code > 0 {
6989        total_tests as f64 / total_code as f64 * 1000.0
6990    } else {
6991        0.0
6992    };
6993    let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
6994        r.totals_by_language
6995            .iter()
6996            .filter(|l| l.test_count > 0)
6997            .count()
6998    });
6999    let most_tested: String = latest_run
7000        .as_ref()
7001        .and_then(|r| {
7002            r.totals_by_language
7003                .iter()
7004                .filter(|l| l.test_count > 0)
7005                .max_by_key(|l| l.test_count)
7006        })
7007        .map_or_else(
7008            || "\u{2014}".to_string(),
7009            |l| l.language.display_name().to_string(),
7010        );
7011    let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
7012        r.per_file_records
7013            .iter()
7014            .filter(|f| f.raw_line_categories.test_count > 0)
7015            .count() as u64
7016    });
7017    let total_files_analyzed: u64 = latest_run
7018        .as_ref()
7019        .map_or(0, |r| r.summary_totals.files_analyzed);
7020    let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
7021
7022    // Aggregated coverage percentages from summary_totals
7023    let cov_line_pct_str: String = latest_run
7024        .as_ref()
7025        .filter(|r| r.summary_totals.coverage_lines_found > 0)
7026        .map_or_else(
7027            || "0".to_string(),
7028            |r| {
7029                format!(
7030                    "{:.1}",
7031                    r.summary_totals.coverage_lines_hit as f64
7032                        / r.summary_totals.coverage_lines_found as f64
7033                        * 100.0
7034                )
7035            },
7036        );
7037    let cov_fn_pct_str: String = latest_run
7038        .as_ref()
7039        .filter(|r| r.summary_totals.coverage_functions_found > 0)
7040        .map_or_else(
7041            || "0".to_string(),
7042            |r| {
7043                format!(
7044                    "{:.1}",
7045                    r.summary_totals.coverage_functions_hit as f64
7046                        / r.summary_totals.coverage_functions_found as f64
7047                        * 100.0
7048                )
7049            },
7050        );
7051    let cov_branch_pct_str: String = latest_run
7052        .as_ref()
7053        .filter(|r| r.summary_totals.coverage_branches_found > 0)
7054        .map_or_else(
7055            || "0".to_string(),
7056            |r| {
7057                format!(
7058                    "{:.1}",
7059                    r.summary_totals.coverage_branches_hit as f64
7060                        / r.summary_totals.coverage_branches_found as f64
7061                        * 100.0
7062                )
7063            },
7064        );
7065
7066    let cov_no_data_notice = if has_coverage {
7067        String::new()
7068    } else {
7069        String::from(
7070            r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
7071<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
7072<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
7073  <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
7074  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
7075  <span style="color:var(--muted);font-size:12px;">&middot;</span>
7076  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
7077  <span style="color:var(--muted);font-size:12px;">&middot;</span>
7078  <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
7079</div>
7080<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
7081</div>"#,
7082        )
7083    };
7084
7085    let workspace_density_str = format!("{workspace_density:.1}");
7086    let nonce = &csp_nonce;
7087    let version = env!("CARGO_PKG_VERSION");
7088
7089    let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
7090        r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
7091            .to_string()
7092    } else {
7093        watched_dirs_list
7094            .iter()
7095            .fold(String::new(), |mut s, d| {
7096                use std::fmt::Write as _;
7097                let escaped =
7098                    d.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
7099                write!(
7100                    s,
7101                    r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button></form></span>"#
7102                ).expect("write to String is infallible");
7103                s
7104            })
7105    };
7106    let watched_dirs_html = format!(
7107        r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">&#8635; Refresh</button></form></div></div>"#
7108    );
7109
7110    // Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
7111    let scope_data_json: String = {
7112        let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
7113        scope_map.insert(
7114            "__all__".to_string(),
7115            latest_run.as_ref().map_or_else(
7116                || {
7117                    serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
7118                        "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"—",
7119                        "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
7120                        "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
7121                        "has_coverage":false,"submodules":{}})
7122                },
7123                build_test_scope_entry,
7124            ),
7125        );
7126        let all_roots: Vec<String> = {
7127            let reg = state.registry.lock().await;
7128            let mut seen = std::collections::BTreeSet::new();
7129            reg.entries
7130                .iter()
7131                .flat_map(|e| e.input_roots.iter().cloned())
7132                .filter(|r| seen.insert(r.clone()))
7133                .collect()
7134        };
7135        for root in &all_roots {
7136            let run_for_root: Option<AnalysisRun> = {
7137                let reg = state.registry.lock().await;
7138                let json_str = reg
7139                    .entries
7140                    .iter()
7141                    .find(|e| e.input_roots.iter().any(|r| r == root))
7142                    .and_then(|e| e.json_path.as_ref())
7143                    .and_then(|p| std::fs::read_to_string(p).ok());
7144                drop(reg);
7145                json_str
7146                    .as_deref()
7147                    .and_then(|s| serde_json::from_str(s).ok())
7148            };
7149            if let Some(ref run) = run_for_root {
7150                scope_map.insert(root.clone(), build_scope_entry_for_run(run));
7151            }
7152        }
7153        serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
7154    };
7155
7156    let html = format!(
7157        r#"<!doctype html>
7158<html lang="en">
7159<head>
7160  <meta charset="utf-8" />
7161  <meta name="viewport" content="width=device-width, initial-scale=1" />
7162  <title>OxideSLOC | Test Metrics</title>
7163  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
7164  <style nonce="{nonce}">
7165    :root {{
7166      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
7167      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
7168      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
7169      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
7170      --info-bg:#eef3ff; --info-text:#4467d8;
7171    }}
7172    body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
7173    *{{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);}}
7174    .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
7175    .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
7176    .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}.code-particle{{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
7177    @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));}}}}
7178    .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);}}
7179    .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
7180    .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));}}
7181    .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
7182    .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;}}
7183    .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
7184    @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
7185    @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; }} }}
7186    .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;}}
7187    .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
7188    .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
7189    .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
7190    .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
7191    .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;}}
7192    .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;}}
7193    .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;}}
7194    .settings-modal{{position:fixed;z-index:9999;background:var(--surface);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;}}
7195    .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
7196    .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);}}
7197    .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;}}
7198    .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;}}
7199    .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;}}
7200    .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
7201    .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;}}
7202    .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);}}
7203    .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;}}
7204    .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;}}
7205    .tz-select:focus{{border-color:var(--oxide);}}
7206    .page{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
7207    .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
7208    h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
7209    .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
7210    .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
7211    @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
7212    .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}}
7213    .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
7214    .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
7215    .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
7216    .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;}}
7217    .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;}}
7218    .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
7219    .stat-chip:hover .stat-chip-tip{{opacity:1;}}
7220    .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);}}
7221    .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
7222    .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
7223    @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
7224    .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
7225    .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
7226    .chart-canvas-wrap{{position:relative;height:280px;}}
7227    .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
7228    .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;}}
7229    .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;}}
7230    .data-table tr:last-child td{{border-bottom:none;}}
7231    .data-table tbody tr:hover td{{background:var(--surface-2);}}
7232    .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
7233    .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
7234    .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
7235    .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
7236    .cov-gauge-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .2s ease,box-shadow .2s ease;min-width:0;}}
7237    .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
7238    .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
7239    .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
7240    .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
7241    .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
7242    .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
7243    @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
7244    .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
7245    .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;}}
7246    .chart-select:focus{{border-color:var(--accent);}}
7247    .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
7248    .trend-canvas-wrap{{position:relative;height:260px;}}
7249    .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
7250    .site-footer a{{color:var(--muted);}}
7251    body.dark-theme .chart-box{{border-color:var(--line-strong);}}
7252    .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;}}
7253    .btn:hover{{background:var(--surface-2);}}
7254    .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 16px;margin-bottom:16px;position:relative;z-index:1;flex-wrap:wrap;}}
7255    .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7256    .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
7257    .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;}}
7258    .scope-sel:focus{{border-color:var(--accent);}}
7259    body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
7260    .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 16px;flex-wrap:wrap;margin-bottom:16px;position:relative;z-index:1;}}
7261    .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
7262    .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
7263    .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
7264    .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;}}
7265    .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
7266    .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
7267    .watched-chip-rm:hover{{color:var(--oxide);}}
7268    .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
7269    .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
7270    .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
7271    body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
7272    .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
7273    .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
7274    .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;}}
7275    .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
7276    .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
7277    .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
7278    .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
7279    .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;}}
7280    .cov-file-search:focus{{border-color:var(--accent);}}
7281    .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
7282    .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;}}
7283    body.dark-theme .cov-file-search{{background:var(--surface);}}
7284  </style>
7285</head>
7286<body>
7287  <div class="background-watermarks" aria-hidden="true">
7288    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7289    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7290    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7291    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7292    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7293    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
7294  </div>
7295  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
7296  <div class="top-nav">
7297    <div class="top-nav-inner">
7298      <a class="brand" href="/">
7299        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
7300        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
7301      </a>
7302      <div class="nav-right">
7303        <a class="nav-pill" href="/">Home</a>
7304        <div class="nav-dropdown">
7305          <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>
7306          <div class="nav-dropdown-menu">
7307            <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>
7308          </div>
7309        </div>
7310        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
7311        <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
7312        <div class="nav-dropdown">
7313          <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>
7314          <div class="nav-dropdown-menu">
7315            <a href="/webhook-setup"><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>
7316          </div>
7317        </div>
7318        <div class="server-status-wrap">
7319          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
7320          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
7321        </div>
7322        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
7323          <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>
7324        </button>
7325        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
7326          <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>
7327          <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>
7328        </button>
7329      </div>
7330    </div>
7331  </div>
7332
7333  <div class="page">
7334    {watched_dirs_html}
7335    <div class="scope-bar">
7336      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
7337      <span class="scope-label">Scope</span>
7338      <div class="scope-sel-wrap">
7339        <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
7340        <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
7341          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
7342          <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
7343        </div>
7344      </div>
7345    </div>
7346    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7347      <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>
7348      <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>
7349      <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>
7350      <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>
7351    </div>
7352    <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
7353      <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>
7354      <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>
7355      <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>
7356      <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>
7357    </div>
7358
7359    <div class="panel">
7360      <h1>Test Metrics</h1>
7361      <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>
7362
7363      <div class="chart-row">
7364        <div class="chart-box">
7365          <div class="chart-box-title">Test Definitions by Language</div>
7366          <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
7367        </div>
7368        <div class="chart-box">
7369          <div class="chart-box-title">Test Density (per 1 000 code lines)</div>
7370          <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
7371        </div>
7372      </div>
7373
7374      <div class="section-header">Language Breakdown</div>
7375      {cov_no_data_notice}
7376      <div style="overflow-x:auto;">
7377        <table class="data-table" id="lang-table">
7378          <thead><tr>
7379            <th>Language</th>
7380            <th class="num">Test Fns</th>
7381            <th class="num">Assertions</th>
7382            <th class="num">Suites</th>
7383            <th class="num">Code Lines</th>
7384            <th class="num">Files</th>
7385            <th class="num">Density / 1K</th>
7386            <th>Relative Density</th>
7387          </tr></thead>
7388          <tbody id="lang-tbody"></tbody>
7389        </table>
7390      </div>
7391    </div>
7392
7393    <div class="panel" id="cov-panel" style="display:none;">
7394      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
7395      <div class="cov-gauge-row" id="cov-gauges">
7396        <div class="cov-gauge-card">
7397          <div class="cov-gauge-label">Line Coverage</div>
7398          <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
7399          <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
7400          <div class="cov-gauge-sub">Lines hit / instrumented</div>
7401        </div>
7402        <div class="cov-gauge-card">
7403          <div class="cov-gauge-label">Function Coverage</div>
7404          <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
7405          <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
7406          <div class="cov-gauge-sub">Functions hit / found</div>
7407        </div>
7408        <div class="cov-gauge-card">
7409          <div class="cov-gauge-label">Branch Coverage</div>
7410          <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
7411          <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
7412          <div class="cov-gauge-sub">Branches hit / found</div>
7413        </div>
7414      </div>
7415      <div class="chart-row">
7416        <div class="chart-box">
7417          <div class="chart-box-title">Line Coverage % by Language</div>
7418          <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
7419        </div>
7420        <div class="chart-box">
7421          <div class="chart-box-title">Coverage Tier Distribution</div>
7422          <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
7423        </div>
7424      </div>
7425
7426      <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
7427      <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
7428      <div class="cov-file-toolbar">
7429        <div class="cov-filter-tabs" id="cov-filter-tabs">
7430          <button class="cov-tab active" data-tier="all">All</button>
7431          <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
7432          <button class="cov-tab" data-tier="low">Low (&lt;50%)</button>
7433          <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
7434          <button class="cov-tab" data-tier="high">High (≥80%)</button>
7435        </div>
7436        <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
7437      </div>
7438      <div style="overflow-x:auto;">
7439        <table class="data-table" id="cov-file-table">
7440          <thead><tr>
7441            <th>File</th>
7442            <th>Lang</th>
7443            <th class="num">Line %</th>
7444            <th class="num">Lines Hit / Found</th>
7445            <th class="num">Fn %</th>
7446            <th class="num">Fns Hit / Found</th>
7447          </tr></thead>
7448          <tbody id="cov-file-tbody"></tbody>
7449        </table>
7450      </div>
7451      <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
7452      <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
7453    </div>
7454
7455    <div class="panel">
7456      <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
7457      <p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</p>
7458      <div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
7459      <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
7460    </div>
7461  </div>
7462
7463  <footer class="site-footer">
7464    oxide-sloc v{version} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
7465    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
7466    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
7467    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
7468    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
7469  </footer>
7470
7471  <script nonce="{nonce}">
7472  (function() {{
7473    // Theme
7474    var b = document.body;
7475    try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
7476    var tgl = document.getElementById('theme-toggle');
7477    if (tgl) tgl.addEventListener('click', function() {{
7478      var d = b.classList.toggle('dark-theme');
7479      try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
7480    }});
7481
7482    // Watermarks
7483    (function() {{
7484      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
7485      if (!wms.length) return;
7486      var placed = [];
7487      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;}}
7488      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];}}
7489      var half=Math.floor(wms.length/2);
7490      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;}});
7491    }})();
7492
7493    // Code particles
7494    (function() {{
7495      var container = document.getElementById('code-particles');
7496      if (!container) return;
7497      var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
7498      for (var i = 0; i < 36; i++) {{
7499        (function(idx) {{
7500          var el = document.createElement('span');
7501          el.className = 'code-particle';
7502          el.textContent = snippets[idx % snippets.length];
7503          var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
7504          var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
7505          var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
7506          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';
7507          container.appendChild(el);
7508        }})(i);
7509      }}
7510    }})();
7511
7512    // Settings modal
7513    (function() {{
7514      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'}}];
7515      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);}});}}
7516      try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
7517      var btn=document.getElementById('settings-btn');if(!btn)return;
7518      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
7519      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
7520      document.body.appendChild(m);
7521      var g=document.getElementById('scheme-grid');
7522      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);}});
7523      var cl=document.getElementById('settings-close');
7524      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');}});
7525      if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
7526      document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
7527    }})();
7528
7529    // Watched folder picker
7530    (function() {{
7531      var btn = document.getElementById('add-watched-btn');
7532      if (!btn) return;
7533      btn.addEventListener('click', function() {{
7534        fetch('/pick-directory?kind=reports')
7535          .then(function(r) {{ return r.json(); }})
7536          .then(function(data) {{
7537            if (!data.cancelled && data.selected_path) {{
7538              var form = document.createElement('form');
7539              form.method = 'POST';
7540              form.action = '/watched-dirs/add';
7541              var ri = document.createElement('input');
7542              ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
7543              var fi = document.createElement('input');
7544              fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
7545              form.appendChild(ri); form.appendChild(fi);
7546              document.body.appendChild(form);
7547              form.submit();
7548            }}
7549          }})
7550          .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
7551      }});
7552    }})();
7553  }})();
7554  </script>
7555
7556  <script src="/static/chart.js" nonce="{nonce}"></script>
7557  <script nonce="{nonce}">
7558  (function() {{
7559    var SCOPE_DATA = {scope_data_json};
7560    var currentRoot = '__all__';
7561    var currentSub  = '';
7562    var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
7563    var ALL_CHARTS = [];
7564
7565    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();}}
7566    function fmtFull(n){{return Number(n).toLocaleString();}}
7567    function isDark(){{return document.body.classList.contains('dark-theme');}}
7568    function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
7569    function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
7570    var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
7571
7572    function getDataset() {{
7573      var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7574      if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
7575      return r;
7576    }}
7577    function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
7578
7579    function renderTestCharts(D) {{
7580      testsChart = destroyChart(testsChart);
7581      densityChart = destroyChart(densityChart);
7582      if (!D || !D.length) return;
7583      var top15 = D.slice(0, 15);
7584      var canvas1 = document.getElementById('canvas-tests');
7585      if (canvas1) {{
7586        testsChart = new Chart(canvas1, {{
7587          type: 'bar',
7588          data: {{
7589            labels: top15.map(function(d){{ return d.lang; }}),
7590            datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
7591          }},
7592          options: {{
7593            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7594            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
7595            scales: {{
7596              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
7597              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7598            }}
7599          }}
7600        }});
7601        ALL_CHARTS.push(testsChart);
7602      }}
7603      var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
7604      var canvas2 = document.getElementById('canvas-density');
7605      if (canvas2) {{
7606        densityChart = new Chart(canvas2, {{
7607          type: 'bar',
7608          data: {{
7609            labels: topD.map(function(d){{ return d.lang; }}),
7610            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 }}]
7611          }},
7612          options: {{
7613            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7614            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
7615            scales: {{
7616              x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
7617              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7618            }}
7619          }}
7620        }});
7621        ALL_CHARTS.push(densityChart);
7622      }}
7623    }}
7624
7625    function renderCovCharts(covD, tiers) {{
7626      covChart = destroyChart(covChart);
7627      tierChart = destroyChart(tierChart);
7628      var covCanvas = document.getElementById('canvas-cov');
7629      if (covCanvas && covD && covD.length) {{
7630        covChart = new Chart(covCanvas, {{
7631          type: 'bar',
7632          data: {{
7633            labels: covD.map(function(d){{ return d.lang; }}),
7634            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 }}]
7635          }},
7636          options: {{
7637            responsive: true, maintainAspectRatio: false, indexAxis: 'y',
7638            plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
7639            scales: {{
7640              x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
7641              y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
7642            }}
7643          }}
7644        }});
7645        ALL_CHARTS.push(covChart);
7646      }}
7647      var tierCanvas = document.getElementById('canvas-cov-tiers');
7648      if (tierCanvas && tiers) {{
7649        var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
7650        tierChart = new Chart(tierCanvas, {{
7651          type: 'doughnut',
7652          data: {{
7653            labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
7654            datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
7655          }},
7656          options: {{
7657            responsive: true, maintainAspectRatio: false, cutout: '62%',
7658            plugins: {{
7659              legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
7660              tooltip: {{ callbacks: {{ label: function(ctx) {{
7661                var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
7662                return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
7663              }} }} }}
7664            }}
7665          }}
7666        }});
7667        ALL_CHARTS.push(tierChart);
7668      }}
7669    }}
7670
7671    function buildLangTable(D) {{
7672      var tbody = document.getElementById('lang-tbody');
7673      if (!tbody) return;
7674      if (!D || !D.length) {{
7675        tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
7676        return;
7677      }}
7678      var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
7679      tbody.innerHTML = D.map(function(d) {{
7680        var barW = Math.round(d.density / maxDensity * 120);
7681        return '<tr>' +
7682          '<td><strong>' + d.lang + '</strong></td>' +
7683          '<td class="num">' + fmt(d.tests) + '</td>' +
7684          '<td class="num">' + fmt(d.assertions || 0) + '</td>' +
7685          '<td class="num">' + fmt(d.suites || 0) + '</td>' +
7686          '<td class="num">' + fmt(d.code) + '</td>' +
7687          '<td class="num">' + fmt(d.files) + '</td>' +
7688          '<td class="num">' + d.density.toFixed(2) + '</td>' +
7689          '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
7690          '</tr>';
7691      }}).join('');
7692    }}
7693
7694    var covFileData = [];
7695    var covFileTier = 'all';
7696    var covFileSearch = '';
7697
7698    function pctBadge(pct) {{
7699      var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
7700      var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
7701      return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
7702    }}
7703
7704    function buildCovFileTable() {{
7705      var tbody = document.getElementById('cov-file-tbody');
7706      var empty = document.getElementById('cov-file-empty');
7707      var count = document.getElementById('cov-file-count');
7708      if (!tbody) return;
7709      var srch = covFileSearch.toLowerCase();
7710      var filtered = covFileData.filter(function(f) {{
7711        if (covFileTier === 'zero' && f.line_pct > 0) return false;
7712        if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
7713        if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
7714        if (covFileTier === 'high' && f.line_pct < 80) return false;
7715        if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
7716        return true;
7717      }});
7718      if (!filtered.length) {{
7719        tbody.innerHTML = '';
7720        if (empty) empty.style.display = '';
7721        if (count) count.textContent = '';
7722        return;
7723      }}
7724      if (empty) empty.style.display = 'none';
7725      var shown = Math.min(filtered.length, 500);
7726      if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
7727      tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
7728        var fnCol = f.fn_pct < 0
7729          ? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
7730          : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
7731        return '<tr>' +
7732          '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '&quot;') + '">' + f.rel + '</td>' +
7733          '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
7734          '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
7735          '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
7736          fnCol +
7737          '</tr>';
7738      }}).join('');
7739    }}
7740
7741    (function() {{
7742      var tabs = document.getElementById('cov-filter-tabs');
7743      if (tabs) {{
7744        tabs.addEventListener('click', function(e) {{
7745          var btn = e.target.closest('.cov-tab');
7746          if (!btn) return;
7747          Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
7748          btn.classList.add('active');
7749          covFileTier = btn.getAttribute('data-tier');
7750          buildCovFileTable();
7751        }});
7752      }}
7753      var srch = document.getElementById('cov-file-search');
7754      if (srch) {{
7755        srch.addEventListener('input', function() {{
7756          covFileSearch = this.value;
7757          buildCovFileTable();
7758        }});
7759      }}
7760    }})();
7761
7762    function updateCovGauges(t) {{
7763      var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
7764      var el;
7765      if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
7766      if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
7767      if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
7768      if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
7769      if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
7770      if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
7771    }}
7772
7773    function applyScope() {{
7774      var d = getDataset();
7775      var t = d.totals;
7776      var el;
7777      if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
7778      if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
7779      if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
7780      if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
7781      if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
7782      if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
7783      if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
7784      if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
7785      renderTestCharts(d.lang_tests);
7786      buildLangTable(d.lang_tests);
7787      var covPanel = document.getElementById('cov-panel');
7788      if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
7789      if (d.has_coverage) {{
7790        renderCovCharts(d.cov, d.cov_tiers);
7791        updateCovGauges(t);
7792        covFileData = d.file_cov || [];
7793        covFileTier = 'all';
7794        covFileSearch = '';
7795        var tabs = document.getElementById('cov-filter-tabs');
7796        if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
7797        var srch = document.getElementById('cov-file-search');
7798        if (srch) srch.value = '';
7799        buildCovFileTable();
7800      }}
7801      loadTrend();
7802    }}
7803
7804    // Populate scope-root-sel from SCOPE_DATA keys
7805    (function() {{
7806      var sel = document.getElementById('scope-root-sel');
7807      if (!sel) return;
7808      Object.keys(SCOPE_DATA).forEach(function(k) {{
7809        if (k === '__all__') return;
7810        var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
7811      }});
7812    }})();
7813
7814    document.getElementById('scope-root-sel').addEventListener('change', function() {{
7815      currentRoot = this.value;
7816      currentSub = '';
7817      var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
7818      var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
7819      var subWrap = document.getElementById('scope-sub-wrap');
7820      var subSel  = document.getElementById('scope-sub-sel');
7821      subSel.innerHTML = '<option value="">Entire project</option>';
7822      if (subNames.length) {{
7823        subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
7824        subWrap.style.display = 'flex';
7825      }} else {{
7826        subWrap.style.display = 'none';
7827      }}
7828      applyScope();
7829    }});
7830
7831    document.getElementById('scope-sub-sel').addEventListener('change', function() {{
7832      currentSub = this.value;
7833      applyScope();
7834    }});
7835
7836    function buildTrend(data) {{
7837      var trendCanvas = document.getElementById('canvas-trend');
7838      var trendEmpty  = document.getElementById('trend-empty');
7839      var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
7840      pts = pts.slice().reverse();
7841      if (!pts.length) {{
7842        if (trendCanvas) trendCanvas.style.display = 'none';
7843        if (trendEmpty) trendEmpty.style.display = '';
7844        return;
7845      }}
7846      if (trendCanvas) trendCanvas.style.display = '';
7847      if (trendEmpty) trendEmpty.style.display = 'none';
7848      trendChart = destroyChart(trendChart);
7849      if (!trendCanvas) return;
7850      trendChart = new Chart(trendCanvas, {{
7851        type: 'line',
7852        data: {{
7853          labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
7854          datasets: [{{
7855            label: 'Test Definitions',
7856            data: pts.map(function(d){{ return d.test_count; }}),
7857            borderColor: '#C45C10',
7858            backgroundColor: 'rgba(196,92,16,0.10)',
7859            pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
7860            pointRadius: 5, fill: true, tension: 0.3
7861          }}]
7862        }},
7863        options: {{
7864          responsive: true, maintainAspectRatio: false,
7865          plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
7866          scales: {{
7867            x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
7868            y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
7869          }}
7870        }}
7871      }});
7872      ALL_CHARTS.push(trendChart);
7873    }}
7874
7875    function loadTrend() {{
7876      var url = '/api/metrics/history?limit=100';
7877      if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
7878      fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
7879        buildTrend(data);
7880      }}).catch(function(){{
7881        var trendEmpty = document.getElementById('trend-empty');
7882        if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
7883      }});
7884    }}
7885
7886    // Re-render charts on theme toggle
7887    document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
7888      setTimeout(function() {{
7889        ALL_CHARTS.forEach(function(c) {{
7890          if (c && c.options && c.options.scales) {{
7891            Object.values(c.options.scales).forEach(function(ax) {{
7892              if (ax.grid) ax.grid.color = clr();
7893              if (ax.ticks) ax.ticks.color = txtClr();
7894            }});
7895            c.update();
7896          }}
7897        }});
7898      }}, 80);
7899    }});
7900
7901    applyScope();
7902  }})();
7903  </script>
7904</body>
7905</html>"#,
7906    );
7907    Html(html).into_response()
7908}
7909
7910// ── Embeddable widget ─────────────────────────────────────────────────────────
7911// Protected. Returns a self-contained HTML page suitable for iframing inside
7912// Jenkins build summaries, Confluence iframe macros, or Jira panels.
7913//
7914// GET /embed/summary?run_id=<uuid>&theme=dark
7915
7916#[derive(Deserialize)]
7917struct EmbedQuery {
7918    run_id: Option<String>,
7919    theme: Option<String>,
7920}
7921
7922async fn embed_handler(
7923    State(state): State<AppState>,
7924    axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7925    Query(query): Query<EmbedQuery>,
7926) -> Response {
7927    let entry = {
7928        let reg = state.registry.lock().await;
7929        query.run_id.as_ref().map_or_else(
7930            || reg.entries.first().cloned(),
7931            |id| reg.find_by_run_id(id).cloned(),
7932        )
7933    };
7934
7935    let Some(entry) = entry else {
7936        return Html(
7937            "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
7938                .to_string(),
7939        )
7940        .into_response();
7941    };
7942
7943    let dark = query.theme.as_deref() == Some("dark");
7944    let languages: Vec<(String, u64, u64)> = entry
7945        .json_path
7946        .as_ref()
7947        .and_then(|p| read_json(p).ok())
7948        .map(|run| {
7949            run.totals_by_language
7950                .iter()
7951                .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
7952                .collect()
7953        })
7954        .unwrap_or_default();
7955
7956    Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
7957}
7958
7959fn render_embed_widget(
7960    entry: &RegistryEntry,
7961    languages: &[(String, u64, u64)],
7962    dark: bool,
7963    csp_nonce: &str,
7964) -> String {
7965    let s = &entry.summary;
7966    let total = s.code_lines + s.comment_lines + s.blank_lines;
7967    let code_pct = s
7968        .code_lines
7969        .checked_mul(100)
7970        .and_then(|n| n.checked_div(total))
7971        .unwrap_or(0);
7972
7973    let (bg, fg, surface, muted, border) = if dark {
7974        ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
7975    } else {
7976        ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
7977    };
7978
7979    let mut lang_rows = String::new();
7980    for (name, files, code) in languages {
7981        write!(
7982            lang_rows,
7983            "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
7984            escape_html(name),
7985            format_number(*files),
7986            format_number(*code),
7987        )
7988        .ok();
7989    }
7990
7991    let lang_table = if lang_rows.is_empty() {
7992        String::new()
7993    } else {
7994        format!(
7995            "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
7996        )
7997    };
7998
7999    let run_short = &entry.run_id[..entry.run_id.len().min(8)];
8000    let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
8001    let project_esc = escape_html(&entry.project_label);
8002    let code_lines = format_number(s.code_lines);
8003    let comment_lines = format_number(s.comment_lines);
8004    let files = format_number(s.files_analyzed);
8005    let code_raw = s.code_lines;
8006    let comment_raw = s.comment_lines;
8007    let blank_raw = s.blank_lines;
8008
8009    format!(
8010        r#"<!doctype html>
8011<html lang="en">
8012<head>
8013  <meta charset="utf-8">
8014  <meta name="viewport" content="width=device-width,initial-scale=1">
8015  <title>OxideSLOC &mdash; {project_esc}</title>
8016  <script src="/static/chart.js"></script>
8017  <style nonce="{csp_nonce}">
8018    *{{box-sizing:border-box;margin:0;padding:0}}
8019    body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
8020    h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
8021    .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
8022    .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
8023    .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
8024    .card .v{{font-size:18px;font-weight:700}}
8025    .card .l{{color:{muted};font-size:10px;margin-top:2px}}
8026    .row{{display:flex;gap:12px;align-items:flex-start}}
8027    .pie{{width:120px;height:120px;flex-shrink:0}}
8028    .lt{{border-collapse:collapse;width:100%;flex:1}}
8029    .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
8030    .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
8031    .n{{text-align:right}}
8032    .footer{{margin-top:10px;color:{muted};font-size:10px}}
8033  </style>
8034</head>
8035<body>
8036  <h2>{project_esc}</h2>
8037  <div class="sub">{timestamp} &middot; run {run_short}</div>
8038  <div class="cards">
8039    <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
8040    <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
8041    <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
8042    <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
8043  </div>
8044  <div class="row">
8045    <canvas class="pie" id="c"></canvas>
8046    {lang_table}
8047  </div>
8048  <div class="footer">oxide-sloc</div>
8049  <script nonce="{csp_nonce}">
8050    new Chart(document.getElementById('c'),{{
8051      type:'doughnut',
8052      data:{{
8053        labels:['Code','Comments','Blank'],
8054        datasets:[{{
8055          data:[{code_raw},{comment_raw},{blank_raw}],
8056          backgroundColor:['#4a78ee','#b35428','#aaa'],
8057          borderWidth:0
8058        }}]
8059      }},
8060      options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
8061    }});
8062  </script>
8063</body>
8064</html>"#
8065    )
8066}
8067
8068#[allow(clippy::too_many_arguments)]
8069fn persist_run_artifacts(
8070    run: &sloc_core::AnalysisRun,
8071    report_html: &str,
8072    run_dir: &Path,
8073    generate_json: bool,
8074    generate_html: bool,
8075    generate_pdf: bool,
8076    report_title: &str,
8077    file_stem: &str,
8078    result_context: RunResultContext,
8079) -> Result<(RunArtifacts, PendingPdf)> {
8080    fs::create_dir_all(run_dir)
8081        .with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
8082
8083    let mut html_path = None;
8084    let mut pdf_path = None;
8085    let mut json_path = None;
8086    let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
8087
8088    if generate_html {
8089        let path = run_dir.join(format!("report_{file_stem}.html"));
8090        fs::write(&path, report_html)
8091            .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
8092        html_path = Some(path);
8093    }
8094
8095    if generate_json {
8096        let path = run_dir.join(format!("result_{file_stem}.json"));
8097        let json = serde_json::to_string_pretty(run)
8098            .context("failed to serialize analysis run to JSON")?;
8099        fs::write(&path, json)
8100            .with_context(|| format!("failed to write JSON report to {}", path.display()))?;
8101        json_path = Some(path);
8102    }
8103
8104    if generate_pdf {
8105        let source_html_path = if let Some(existing) = html_path.as_ref() {
8106            existing.clone()
8107        } else {
8108            let temp_html = run_dir.join("_report_rendered.html");
8109            fs::write(&temp_html, report_html).with_context(|| {
8110                format!(
8111                    "failed to write temporary HTML report to {}",
8112                    temp_html.display()
8113                )
8114            })?;
8115            temp_html
8116        };
8117
8118        let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
8119        let cleanup_src = !generate_html;
8120        pdf_path = Some(pdf_dest.clone());
8121        pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
8122    }
8123
8124    // CSV and XLSX are always generated (like JSON) — no extra flag required.
8125    let csv_path = {
8126        let path = run_dir.join(format!("report_{file_stem}.csv"));
8127        if let Err(e) = sloc_report::write_csv(run, &path) {
8128            eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
8129            None
8130        } else {
8131            Some(path)
8132        }
8133    };
8134
8135    let xlsx_path = {
8136        let path = run_dir.join(format!("report_{file_stem}.xlsx"));
8137        if let Err(e) = sloc_report::write_xlsx(run, &path) {
8138            eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
8139            None
8140        } else {
8141            Some(path)
8142        }
8143    };
8144
8145    let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
8146
8147    Ok((
8148        RunArtifacts {
8149            output_dir: run_dir.to_path_buf(),
8150            html_path,
8151            pdf_path,
8152            json_path,
8153            csv_path,
8154            xlsx_path,
8155            scan_config_path,
8156            report_title: report_title.to_string(),
8157            result_context,
8158        },
8159        pending_pdf,
8160    ))
8161}
8162
8163/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
8164/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
8165fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
8166    let exact = dir.join("scan-config.json");
8167    if exact.exists() {
8168        return Some(exact);
8169    }
8170    fs::read_dir(dir).ok().and_then(|entries| {
8171        entries
8172            .filter_map(std::result::Result::ok)
8173            .find(|e| {
8174                let name = e.file_name();
8175                let name = name.to_string_lossy();
8176                name.starts_with("scan-config") && name.ends_with(".json")
8177            })
8178            .map(|e| e.path())
8179    })
8180}
8181
8182// ── Config export / import ────────────────────────────────────────────────────
8183
8184async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
8185    let toml_str = match toml::to_string_pretty(&state.base_config) {
8186        Ok(s) => s,
8187        Err(e) => {
8188            return (
8189                StatusCode::INTERNAL_SERVER_ERROR,
8190                format!("serialization error: {e}"),
8191            )
8192                .into_response();
8193        }
8194    };
8195    (
8196        [
8197            (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
8198            (
8199                header::CONTENT_DISPOSITION,
8200                "attachment; filename=\".oxide-sloc.toml\"",
8201            ),
8202        ],
8203        toml_str,
8204    )
8205        .into_response()
8206}
8207
8208#[derive(Serialize)]
8209struct OkResponse {
8210    ok: bool,
8211}
8212
8213#[derive(Serialize)]
8214struct SaveProfileResponse {
8215    ok: bool,
8216    id: String,
8217}
8218
8219#[derive(Serialize)]
8220struct ProfileListResponse {
8221    profiles: Vec<ScanProfile>,
8222}
8223
8224#[derive(Serialize)]
8225struct ImportConfigResponse {
8226    ok: bool,
8227    config: sloc_config::AppConfig,
8228}
8229
8230#[derive(Deserialize)]
8231struct ImportConfigBody {
8232    toml: String,
8233}
8234
8235async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
8236    match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
8237        Ok(config) => {
8238            if let Err(e) = config.validate() {
8239                return error::unprocessable_entity(&e.to_string());
8240            }
8241            Json(ImportConfigResponse { ok: true, config }).into_response()
8242        }
8243        Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
8244    }
8245}
8246
8247// ── Scan profiles API ─────────────────────────────────────────────────────────
8248
8249async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
8250    let store = state.scan_profiles.lock().await;
8251    Json(ProfileListResponse {
8252        profiles: store.profiles.clone(),
8253    })
8254}
8255
8256#[derive(Deserialize)]
8257struct SaveScanProfileBody {
8258    name: String,
8259    params: serde_json::Value,
8260}
8261
8262async fn api_save_scan_profile(
8263    State(state): State<AppState>,
8264    Json(body): Json<SaveScanProfileBody>,
8265) -> impl IntoResponse {
8266    if body.name.trim().is_empty() {
8267        return error::bad_request("name must not be empty");
8268    }
8269
8270    let id = uuid::Uuid::new_v4().to_string();
8271    let profile = ScanProfile {
8272        id: id.clone(),
8273        name: body.name.trim().to_string(),
8274        created_at: chrono::Utc::now().to_rfc3339(),
8275        params: body.params,
8276    };
8277
8278    let mut store = state.scan_profiles.lock().await;
8279    store.profiles.push(profile);
8280    if let Err(e) = store.save(&state.scan_profiles_path) {
8281        tracing::warn!("failed to persist scan profiles: {e}");
8282    }
8283    drop(store);
8284
8285    (
8286        StatusCode::CREATED,
8287        Json(SaveProfileResponse { ok: true, id }),
8288    )
8289        .into_response()
8290}
8291
8292async fn api_delete_scan_profile(
8293    State(state): State<AppState>,
8294    AxumPath(id): AxumPath<String>,
8295) -> impl IntoResponse {
8296    let mut store = state.scan_profiles.lock().await;
8297    let before = store.profiles.len();
8298    store.profiles.retain(|p| p.id != id);
8299    if store.profiles.len() == before {
8300        drop(store);
8301        return error::not_found("profile not found");
8302    }
8303    if let Err(e) = store.save(&state.scan_profiles_path) {
8304        tracing::warn!("failed to persist scan profiles: {e}");
8305    }
8306    drop(store);
8307    Json(OkResponse { ok: true }).into_response()
8308}
8309
8310fn resolve_output_root(raw: Option<&str>) -> PathBuf {
8311    let value = raw.unwrap_or("out/web").trim();
8312    let path = if value.is_empty() {
8313        PathBuf::from("out/web")
8314    } else {
8315        PathBuf::from(value)
8316    };
8317
8318    if path.is_absolute() {
8319        path
8320    } else {
8321        workspace_root().join(path)
8322    }
8323}
8324
8325/// Derive the directory that holds remote-repo clones from the output root.
8326fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
8327    std::env::var("SLOC_GIT_CLONES_DIR")
8328        .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
8329}
8330
8331/// Build a deterministic filesystem path for a cloned remote repository.
8332/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
8333pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
8334    let safe: String = repo_url
8335        .chars()
8336        .map(|c| {
8337            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
8338                c
8339            } else {
8340                '_'
8341            }
8342        })
8343        .take(80)
8344        .collect();
8345    clones_dir.join(safe)
8346}
8347
8348/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
8349/// Runs synchronously — call from `tokio::task::spawn_blocking`.
8350pub(crate) fn scan_path_to_artifacts(
8351    scan_path: &Path,
8352    base_config: &AppConfig,
8353    label: &str,
8354) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
8355    let mut config = base_config.clone();
8356    config.discovery.root_paths = vec![scan_path.to_path_buf()];
8357    label.clone_into(&mut config.reporting.report_title);
8358    let run = analyze(&config, "git", None)?;
8359    let html = render_html(&run)?;
8360    let run_id = run.tool.run_id.clone();
8361    let project_label = sanitize_project_label(label);
8362    let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
8363    let file_stem = {
8364        let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
8365        if commit.is_empty() {
8366            project_label
8367        } else {
8368            format!("{project_label}_{commit}")
8369        }
8370    };
8371    let (artifacts, _pending_pdf) = persist_run_artifacts(
8372        &run,
8373        &html,
8374        &output_dir,
8375        true,
8376        true,
8377        false,
8378        label,
8379        &file_stem,
8380        RunResultContext::default(),
8381    )?;
8382    Ok((run_id, artifacts, run))
8383}
8384
8385/// Re-spawn background poll tasks for any polling schedules saved to disk.
8386async fn restart_poll_schedules(state: &AppState) {
8387    let store = state.schedules.lock().await;
8388    let poll_schedules: Vec<_> = store
8389        .schedules
8390        .iter()
8391        .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
8392        .cloned()
8393        .collect();
8394    drop(store);
8395    for schedule in poll_schedules {
8396        let interval = schedule.interval_secs.unwrap_or(300);
8397        let st = state.clone();
8398        tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
8399    }
8400}
8401
8402fn split_patterns(raw: Option<&str>) -> Vec<String> {
8403    raw.unwrap_or("")
8404        .lines()
8405        .flat_map(|line| line.split(','))
8406        .map(str::trim)
8407        .filter(|part| !part.is_empty())
8408        .map(ToOwned::to_owned)
8409        .collect()
8410}
8411
8412fn build_sub_run(
8413    parent: &AnalysisRun,
8414    sub: &sloc_core::SubmoduleSummary,
8415    parent_path: &str,
8416) -> AnalysisRun {
8417    let sub_files: Vec<_> = parent
8418        .per_file_records
8419        .iter()
8420        .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
8421        .cloned()
8422        .collect();
8423    let mut config = parent.effective_configuration.clone();
8424    config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
8425    AnalysisRun {
8426        tool: parent.tool.clone(),
8427        environment: parent.environment.clone(),
8428        effective_configuration: config,
8429        input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
8430        summary_totals: SummaryTotals {
8431            files_considered: sub.files_analyzed,
8432            files_analyzed: sub.files_analyzed,
8433            files_skipped: 0,
8434            total_physical_lines: sub.total_physical_lines,
8435            code_lines: sub.code_lines,
8436            comment_lines: sub.comment_lines,
8437            blank_lines: sub.blank_lines,
8438            mixed_lines_separate: 0,
8439            functions: 0,
8440            classes: 0,
8441            variables: 0,
8442            imports: 0,
8443            test_count: 0,
8444            test_assertion_count: 0,
8445            test_suite_count: 0,
8446            coverage_lines_found: 0,
8447            coverage_lines_hit: 0,
8448            coverage_functions_found: 0,
8449            coverage_functions_hit: 0,
8450            coverage_branches_found: 0,
8451            coverage_branches_hit: 0,
8452        },
8453        totals_by_language: sub.language_summaries.clone(),
8454        per_file_records: sub_files,
8455        skipped_file_records: vec![],
8456        warnings: vec![],
8457        submodule_summaries: vec![],
8458        git_commit_short: parent.git_commit_short.clone(),
8459        git_commit_long: parent.git_commit_long.clone(),
8460        git_branch: parent.git_branch.clone(),
8461        git_commit_author: parent.git_commit_author.clone(),
8462        git_commit_date: parent.git_commit_date.clone(),
8463        git_tags: parent.git_tags.clone(),
8464        git_nearest_tag: parent.git_nearest_tag.clone(),
8465    }
8466}
8467
8468pub(crate) fn sanitize_project_label(raw: &str) -> String {
8469    let candidate = Path::new(raw)
8470        .file_name()
8471        .and_then(|name| name.to_str())
8472        .unwrap_or("project");
8473
8474    let mut value = String::with_capacity(candidate.len());
8475    for ch in candidate.chars() {
8476        if ch.is_ascii_alphanumeric() {
8477            value.push(ch.to_ascii_lowercase());
8478        } else {
8479            value.push('-');
8480        }
8481    }
8482
8483    let compact = value.trim_matches('-').to_string();
8484    if compact.is_empty() {
8485        "project".to_string()
8486    } else {
8487        compact
8488    }
8489}
8490
8491/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
8492/// comparisons with non-canonicalized stored paths work correctly.
8493fn strip_unc_prefix(path: PathBuf) -> PathBuf {
8494    let s = path.to_string_lossy();
8495    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8496        return PathBuf::from(format!(r"\\{rest}"));
8497    }
8498    if let Some(rest) = s.strip_prefix(r"\\?\") {
8499        return PathBuf::from(rest);
8500    }
8501    path
8502}
8503
8504fn display_path(path: &Path) -> String {
8505    let s = path.to_string_lossy();
8506    // Strip Windows extended-length prefix for display only; the underlying
8507    // PathBuf remains unchanged so file operations are unaffected.
8508    // \\?\UNC\server\share  →  \\server\share   (file share / SMB)
8509    // \\?\C:\path           →  C:\path          (local drive)
8510    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
8511        return format!(r"\\{rest}");
8512    }
8513    if let Some(rest) = s.strip_prefix(r"\\?\") {
8514        return rest.to_owned();
8515    }
8516    s.into_owned()
8517}
8518
8519fn sanitize_path_str(s: &str) -> String {
8520    // Forward-slash variants of the Windows extended-length prefix that appear
8521    // when paths stored as plain strings have been processed through some path
8522    // normalisation (e.g. //?/C:/... instead of \\?\C:\...).
8523    if let Some(rest) = s.strip_prefix("//?/UNC/") {
8524        return format!("//{rest}");
8525    }
8526    if let Some(rest) = s.strip_prefix("//?/") {
8527        return rest.to_owned();
8528    }
8529    display_path(Path::new(s))
8530}
8531
8532fn workspace_root() -> PathBuf {
8533    // OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
8534    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
8535        let p = PathBuf::from(root);
8536        if p.is_dir() {
8537            return p;
8538        }
8539    }
8540
8541    // Current working directory — works for `cargo run` from the project root
8542    // and for scripts/run.sh which cds there first.
8543    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
8544}
8545
8546/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
8547fn make_git_label(repo: &str, ref_name: &str) -> String {
8548    if repo.is_empty() || ref_name.is_empty() {
8549        return String::new();
8550    }
8551    let base = repo
8552        .trim_end_matches('/')
8553        .trim_end_matches(".git")
8554        .rsplit('/')
8555        .next()
8556        .unwrap_or("repo");
8557    let ref_safe: String = ref_name
8558        .chars()
8559        .map(|c| {
8560            if c.is_alphanumeric() || c == '-' || c == '.' {
8561                c
8562            } else {
8563                '_'
8564            }
8565        })
8566        .collect();
8567    format!("{base}_at_{ref_safe}_sloc")
8568}
8569
8570/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
8571fn desktop_dir() -> PathBuf {
8572    if let Ok(profile) = std::env::var("USERPROFILE") {
8573        let p = PathBuf::from(profile).join("Desktop");
8574        if p.exists() {
8575            return p;
8576        }
8577    }
8578    if let Ok(home) = std::env::var("HOME") {
8579        let p = PathBuf::from(home).join("Desktop");
8580        if p.exists() {
8581            return p;
8582        }
8583    }
8584    workspace_root().join("out").join("web")
8585}
8586
8587fn resolve_input_path(raw: &str) -> PathBuf {
8588    let trimmed = raw.trim();
8589    if trimmed.is_empty() {
8590        return workspace_root().join("samples").join("basic");
8591    }
8592
8593    let candidate = PathBuf::from(trimmed);
8594    let resolved = if candidate.is_absolute() {
8595        candidate
8596    } else {
8597        let rooted = workspace_root().join(&candidate);
8598        if rooted.exists() {
8599            rooted
8600        } else {
8601            workspace_root().join(candidate)
8602        }
8603    };
8604
8605    // fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
8606    // strip that prefix so stored paths and the displayed "Project path" are clean.
8607    let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
8608    PathBuf::from(display_path(&canonical))
8609}
8610
8611fn dir_size_bytes(path: &Path) -> u64 {
8612    let mut total = 0u64;
8613    if let Ok(rd) = fs::read_dir(path) {
8614        for entry in rd.filter_map(Result::ok) {
8615            let p = entry.path();
8616            if p.is_file() {
8617                if let Ok(meta) = p.metadata() {
8618                    total += meta.len();
8619                }
8620            } else if p.is_dir() {
8621                total += dir_size_bytes(&p);
8622            }
8623        }
8624    }
8625    total
8626}
8627
8628#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
8629fn format_dir_size(bytes: u64) -> String {
8630    if bytes >= 1_073_741_824 {
8631        format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
8632    } else if bytes >= 1_048_576 {
8633        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
8634    } else if bytes >= 1_024 {
8635        format!("{:.0} KB", bytes as f64 / 1_024.0)
8636    } else {
8637        format!("{bytes} B")
8638    }
8639}
8640
8641fn render_submodule_chips(
8642    root: &Path,
8643    submodules: &[(String, std::path::PathBuf)],
8644    out: &mut String,
8645) {
8646    use std::fmt::Write as _;
8647    let count = submodules.len();
8648    out.push_str(r#"<div class="submodule-preview-strip">"#);
8649    write!(
8650        out,
8651        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>"#,
8652        if count == 1 { "" } else { "s" }
8653    )
8654    .ok();
8655    out.push_str(r#"<div class="submodule-preview-chips">"#);
8656    for (sub_name, sub_rel_path) in submodules {
8657        let sub_abs = root.join(sub_rel_path);
8658        let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
8659        let mut sub_stats = PreviewStats::default();
8660        let mut sub_rows: Vec<PreviewRow> = Vec::new();
8661        let mut sub_langs: Vec<&'static str> = Vec::new();
8662        let mut sub_budget = PreviewBudget {
8663            shown: 0,
8664            max_entries: 2000,
8665            max_depth: 9,
8666        };
8667        let mut sub_next_id = 1usize;
8668        let _ = collect_preview_rows(
8669            &sub_abs,
8670            &sub_abs,
8671            0,
8672            None,
8673            &mut sub_next_id,
8674            &mut sub_budget,
8675            &mut sub_stats,
8676            &mut sub_rows,
8677            &mut sub_langs,
8678            &[],
8679            &[],
8680        );
8681        let stats_json = format!(
8682            r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
8683            sub_stats.directories,
8684            sub_stats.files,
8685            sub_stats.supported,
8686            sub_stats.skipped,
8687            sub_stats.unsupported
8688        );
8689        write!(
8690            out,
8691            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>"#,
8692            escape_html(sub_name),
8693            escape_html(&sub_rel_path.to_string_lossy()),
8694            escape_html(&sub_size),
8695            escape_html(&stats_json),
8696            escape_html(sub_name),
8697            escape_html(&sub_size),
8698        )
8699        .ok();
8700    }
8701    out.push_str(
8702        r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">&#8593; Base repo</button>"#,
8703    );
8704    out.push_str(r"</div>");
8705}
8706
8707fn render_language_pills_row(languages: &[&str], out: &mut String) {
8708    use std::fmt::Write as _;
8709    if languages.is_empty() {
8710        out.push_str(
8711            r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
8712        );
8713        return;
8714    }
8715    out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
8716    for language in languages {
8717        if let Some(icon) = language_icon_file(language) {
8718            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();
8719        } else if let Some(svg) = language_inline_svg(language) {
8720            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();
8721        } else {
8722            write!(
8723                out,
8724                r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
8725                escape_html(&language.to_ascii_lowercase()),
8726                escape_html(language)
8727            )
8728            .ok();
8729        }
8730    }
8731}
8732
8733#[allow(clippy::too_many_lines)]
8734fn build_preview_html(
8735    root: &Path,
8736    include_patterns: &[String],
8737    exclude_patterns: &[String],
8738) -> Result<String> {
8739    if !root.exists() {
8740        return Ok(format!(
8741            r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
8742            escape_html(&display_path(root))
8743        ));
8744    }
8745
8746    let _selected = display_path(root);
8747    let mut stats = PreviewStats::default();
8748    let mut rows = Vec::new();
8749    let mut languages = Vec::new();
8750    let mut budget = PreviewBudget {
8751        shown: 0,
8752        max_entries: 600,
8753        max_depth: 9,
8754    };
8755    let mut next_row_id = 1usize;
8756
8757    let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
8758        || root.to_string_lossy().into_owned(),
8759        std::string::ToString::to_string,
8760    );
8761    let root_modified = root
8762        .metadata()
8763        .ok()
8764        .and_then(|meta| meta.modified().ok())
8765        .map_or_else(|| "-".to_string(), format_system_time);
8766
8767    rows.push(PreviewRow {
8768        row_id: 0,
8769        parent_row_id: None,
8770        depth: 0,
8771        name: format!("{root_name}/"),
8772        kind: PreviewKind::Dir,
8773        is_dir: true,
8774        language: None,
8775        modified: root_modified,
8776        type_label: "Directory".to_string(),
8777    });
8778    collect_preview_rows(
8779        root,
8780        root,
8781        0,
8782        Some(0),
8783        &mut next_row_id,
8784        &mut budget,
8785        &mut stats,
8786        &mut rows,
8787        &mut languages,
8788        include_patterns,
8789        exclude_patterns,
8790    )?;
8791
8792    let root_size = format_dir_size(dir_size_bytes(root));
8793
8794    let mut out = String::new();
8795    write!(
8796        out,
8797        r#"<div class="explorer-wrap" data-project-size="{}">"#,
8798        escape_html(&root_size)
8799    )
8800    .ok();
8801    out.push_str(r#"<div class="explorer-toolbar compact">"#);
8802    out.push_str(r#"<div class="explorer-title-group">"#);
8803    out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
8804    out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
8805    out.push_str(r"</div></div>");
8806
8807    out.push_str(r#"<div class="scope-stats">"#);
8808    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();
8809    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();
8810    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();
8811    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();
8812    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();
8813    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>"#);
8814    out.push_str(r"</div>");
8815
8816    let submodules = sloc_core::detect_submodules(root);
8817    if !submodules.is_empty() {
8818        render_submodule_chips(root, &submodules, &mut out);
8819    }
8820
8821    out.push_str(r#"<div class="scope-info-row">"#);
8822    out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
8823    render_language_pills_row(&languages, &mut out);
8824    out.push_str(r"</div></div>");
8825    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>"#);
8826    out.push_str(r"</div>");
8827
8828    out.push_str(r#"<div class="file-explorer-shell">"#);
8829    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>"#);
8830    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>"#);
8831    out.push_str(r#"<div class="file-explorer-tree">"#);
8832    for row in rows {
8833        let status_label = row.kind.label();
8834        let lang_attr = row.language.unwrap_or("");
8835        let toggle_html = if row.is_dir {
8836            r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
8837                .to_string()
8838        } else {
8839            r#"<span class="tree-bullet">•</span>"#.to_string()
8840        };
8841        write!(out, r#"<div class="tree-row kind-{} status-{}" data-kind="{}" data-status="{}" data-language="{}" data-row-id="{}" data-parent-id="{}" data-dir="{}" data-expanded="true" data-name-lower="{}" data-sort-name="{}" data-sort-date="{}" data-sort-type="{}" data-sort-status="{}"><div class="tree-name-cell" style="--depth:{}">{}<span class="tree-node {}">{}</span></div><div class="tree-date-cell">{}</div><div class="tree-type-cell">{}</div><div class="tree-status-cell"><span class="badge {}">{}</span></div></div>"#, if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), if row.is_dir { "dir" } else { "file" }, row.kind.filter_key(), escape_html(lang_attr), row.row_id, row.parent_row_id.map(|id| id.to_string()).unwrap_or_default(), if row.is_dir { "true" } else { "false" }, escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.name.to_ascii_lowercase()), escape_html(&row.modified), escape_html(&row.type_label.to_ascii_lowercase()), escape_html(status_label), row.depth, toggle_html, if row.is_dir { "tree-node-dir" } else { row.kind.node_class() }, escape_html(&row.name), escape_html(&row.modified), escape_html(&row.type_label), row.kind.badge_class(), status_label).ok();
8842    }
8843    if budget.shown >= budget.max_entries {
8844        out.push_str(r#"<div class="tree-row more-row" data-kind="file" data-status="more" data-row-id="999999" data-parent-id="" data-dir="false" data-expanded="true" data-name-lower="preview truncated"><div class="tree-name-cell" style="--depth:0"><span class="tree-bullet">•</span><span class="tree-node tree-node-more">... preview truncated for readability ...</span></div><div class="tree-date-cell">-</div><div class="tree-type-cell">Preview note</div><div class="tree-status-cell"></div></div>"#);
8845    }
8846    out.push_str(r"</div></div></div>");
8847
8848    Ok(out)
8849}
8850
8851#[derive(Default)]
8852struct PreviewStats {
8853    directories: usize,
8854    files: usize,
8855    supported: usize,
8856    skipped: usize,
8857    unsupported: usize,
8858}
8859
8860struct PreviewRow {
8861    row_id: usize,
8862    parent_row_id: Option<usize>,
8863    depth: usize,
8864    name: String,
8865    kind: PreviewKind,
8866    is_dir: bool,
8867    language: Option<&'static str>,
8868    modified: String,
8869    type_label: String,
8870}
8871
8872#[derive(Copy, Clone)]
8873enum PreviewKind {
8874    Dir,
8875    Supported,
8876    Skipped,
8877    Unsupported,
8878}
8879
8880impl PreviewKind {
8881    const fn filter_key(self) -> &'static str {
8882        match self {
8883            Self::Dir => "dir",
8884            Self::Supported => "supported",
8885            Self::Skipped => "skipped",
8886            Self::Unsupported => "unsupported",
8887        }
8888    }
8889
8890    const fn label(self) -> &'static str {
8891        match self {
8892            Self::Dir => "dir",
8893            Self::Supported => "supported",
8894            Self::Skipped => "skipped by policy",
8895            Self::Unsupported => "unsupported",
8896        }
8897    }
8898
8899    const fn badge_class(self) -> &'static str {
8900        match self {
8901            Self::Dir => "badge badge-dir",
8902            Self::Supported => "badge badge-scan",
8903            Self::Skipped => "badge badge-skip",
8904            Self::Unsupported => "badge badge-unsupported",
8905        }
8906    }
8907
8908    const fn node_class(self) -> &'static str {
8909        match self {
8910            Self::Dir => "tree-node-dir",
8911            Self::Supported => "tree-node-supported",
8912            Self::Skipped => "tree-node-skipped",
8913            Self::Unsupported => "tree-node-unsupported",
8914        }
8915    }
8916}
8917
8918struct PreviewBudget {
8919    shown: usize,
8920    max_entries: usize,
8921    max_depth: usize,
8922}
8923
8924/// Handle a single directory entry inside `collect_preview_rows`.
8925/// Returns `true` when the entry was handled (caller should `continue`).
8926#[allow(clippy::too_many_arguments)]
8927fn handle_preview_dir_entry(
8928    root: &Path,
8929    path: &Path,
8930    name: &str,
8931    modified: String,
8932    depth: usize,
8933    parent_row_id: Option<usize>,
8934    row_id: usize,
8935    next_row_id: &mut usize,
8936    budget: &mut PreviewBudget,
8937    stats: &mut PreviewStats,
8938    rows: &mut Vec<PreviewRow>,
8939    languages: &mut Vec<&'static str>,
8940    include_patterns: &[String],
8941    exclude_patterns: &[String],
8942) -> Result<()> {
8943    let relative = preview_relative_path(root, path);
8944    if should_skip_preview_directory(&relative, exclude_patterns) {
8945        return Ok(());
8946    }
8947    stats.directories += 1;
8948    rows.push(PreviewRow {
8949        row_id,
8950        parent_row_id,
8951        depth: depth + 1,
8952        name: format!("{name}/"),
8953        kind: PreviewKind::Dir,
8954        is_dir: true,
8955        language: None,
8956        modified,
8957        type_label: "Directory".to_string(),
8958    });
8959    budget.shown += 1;
8960    if !matches!(name, ".git" | "node_modules" | "target") {
8961        collect_preview_rows(
8962            root,
8963            path,
8964            depth + 1,
8965            Some(row_id),
8966            next_row_id,
8967            budget,
8968            stats,
8969            rows,
8970            languages,
8971            include_patterns,
8972            exclude_patterns,
8973        )?;
8974    }
8975    Ok(())
8976}
8977
8978/// Handle a single file entry inside `collect_preview_rows`.
8979#[allow(clippy::too_many_arguments)]
8980fn handle_preview_file_entry(
8981    root: &Path,
8982    path: &Path,
8983    name: &str,
8984    modified: String,
8985    depth: usize,
8986    parent_row_id: Option<usize>,
8987    row_id: usize,
8988    budget: &mut PreviewBudget,
8989    stats: &mut PreviewStats,
8990    rows: &mut Vec<PreviewRow>,
8991    languages: &mut Vec<&'static str>,
8992    include_patterns: &[String],
8993    exclude_patterns: &[String],
8994) {
8995    let relative = preview_relative_path(root, path);
8996    if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
8997        return;
8998    }
8999    stats.files += 1;
9000    let kind = classify_preview_file(name);
9001    match kind {
9002        PreviewKind::Supported => stats.supported += 1,
9003        PreviewKind::Skipped => stats.skipped += 1,
9004        PreviewKind::Unsupported => stats.unsupported += 1,
9005        PreviewKind::Dir => {}
9006    }
9007    let language = detect_language_name(name);
9008    if let Some(lang) = language {
9009        if !languages.contains(&lang) {
9010            languages.push(lang);
9011        }
9012    }
9013    rows.push(PreviewRow {
9014        row_id,
9015        parent_row_id,
9016        depth: depth + 1,
9017        name: name.to_owned(),
9018        kind,
9019        is_dir: false,
9020        language,
9021        modified,
9022        type_label: preview_type_label(name, language, kind),
9023    });
9024    budget.shown += 1;
9025}
9026
9027#[allow(clippy::too_many_arguments)]
9028#[allow(clippy::too_many_lines)]
9029fn collect_preview_rows(
9030    root: &Path,
9031    dir: &Path,
9032    depth: usize,
9033    parent_row_id: Option<usize>,
9034    next_row_id: &mut usize,
9035    budget: &mut PreviewBudget,
9036    stats: &mut PreviewStats,
9037    rows: &mut Vec<PreviewRow>,
9038    languages: &mut Vec<&'static str>,
9039    include_patterns: &[String],
9040    exclude_patterns: &[String],
9041) -> Result<()> {
9042    if depth >= budget.max_depth || budget.shown >= budget.max_entries {
9043        return Ok(());
9044    }
9045
9046    let mut entries = fs::read_dir(dir)
9047        .with_context(|| format!("failed to read directory {}", dir.display()))?
9048        .filter_map(std::result::Result::ok)
9049        .collect::<Vec<_>>();
9050    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
9051
9052    for entry in entries {
9053        if budget.shown >= budget.max_entries {
9054            break;
9055        }
9056
9057        let path = entry.path();
9058        let name = entry.file_name().to_string_lossy().into_owned();
9059        let Ok(metadata) = entry.metadata() else {
9060            continue;
9061        };
9062        let row_id = *next_row_id;
9063        *next_row_id += 1;
9064        let modified = metadata
9065            .modified()
9066            .ok()
9067            .map_or_else(|| "-".to_string(), format_system_time);
9068
9069        if metadata.is_dir() {
9070            handle_preview_dir_entry(
9071                root,
9072                &path,
9073                &name,
9074                modified,
9075                depth,
9076                parent_row_id,
9077                row_id,
9078                next_row_id,
9079                budget,
9080                stats,
9081                rows,
9082                languages,
9083                include_patterns,
9084                exclude_patterns,
9085            )?;
9086            continue;
9087        }
9088
9089        if metadata.is_file() {
9090            handle_preview_file_entry(
9091                root,
9092                &path,
9093                &name,
9094                modified,
9095                depth,
9096                parent_row_id,
9097                row_id,
9098                budget,
9099                stats,
9100                rows,
9101                languages,
9102                include_patterns,
9103                exclude_patterns,
9104            );
9105        }
9106    }
9107
9108    Ok(())
9109}
9110
9111fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
9112    if let Some(language) = language {
9113        return format!("{language} source");
9114    }
9115    let lower = name.to_ascii_lowercase();
9116    let ext = Path::new(&lower)
9117        .extension()
9118        .and_then(|e| e.to_str())
9119        .unwrap_or("");
9120    match kind {
9121        PreviewKind::Skipped => {
9122            if lower.ends_with(".min.js") {
9123                "Minified asset".to_string()
9124            } else if [
9125                "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
9126            ]
9127            .contains(&ext)
9128            {
9129                "Binary or archive".to_string()
9130            } else {
9131                "Skipped file".to_string()
9132            }
9133        }
9134        PreviewKind::Unsupported => {
9135            if ext.is_empty() {
9136                "Unsupported file".to_string()
9137            } else {
9138                format!("{} file", ext.to_ascii_uppercase())
9139            }
9140        }
9141        PreviewKind::Supported => "Supported source".to_string(),
9142        PreviewKind::Dir => "Directory".to_string(),
9143    }
9144}
9145
9146fn format_system_time(time: SystemTime) -> String {
9147    #[allow(clippy::cast_possible_wrap)]
9148    let secs = match time.duration_since(UNIX_EPOCH) {
9149        Ok(duration) => duration.as_secs() as i64,
9150        Err(_) => return "-".to_string(),
9151    };
9152    let days = secs.div_euclid(86_400);
9153    let secs_of_day = secs.rem_euclid(86_400);
9154    let (year, month, day) = civil_from_days(days);
9155    let hour = secs_of_day / 3_600;
9156    let minute = (secs_of_day % 3_600) / 60;
9157    format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
9158}
9159
9160#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
9161fn civil_from_days(days: i64) -> (i32, u32, u32) {
9162    let z = days + 719_468;
9163    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
9164    let doe = z - era * 146_097;
9165    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
9166    let y = yoe + era * 400;
9167    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
9168    let mp = (5 * doy + 2) / 153;
9169    let d = doy - (153 * mp + 2) / 5 + 1;
9170    let m = mp + if mp < 10 { 3 } else { -9 };
9171    let year = y + i64::from(m <= 2);
9172    (year as i32, m as u32, d as u32)
9173}
9174
9175// The input is already lowercased via `to_ascii_lowercase()` before calling
9176// `ends_with`, so the comparisons are inherently case-insensitive.
9177#[allow(clippy::case_sensitive_file_extension_comparisons)]
9178fn detect_language_name(name: &str) -> Option<&'static str> {
9179    let lower = name.to_ascii_lowercase();
9180    if lower.ends_with(".c") || lower.ends_with(".h") {
9181        Some("C")
9182    } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
9183        .iter()
9184        .any(|s| lower.ends_with(s))
9185    {
9186        Some("C++")
9187    } else if lower.ends_with(".cs") {
9188        Some("C#")
9189    } else if lower.ends_with(".py") {
9190        Some("Python")
9191    } else if lower.ends_with(".sh") {
9192        Some("Shell")
9193    } else if [".ps1", ".psm1", ".psd1"]
9194        .iter()
9195        .any(|s| lower.ends_with(s))
9196    {
9197        Some("PowerShell")
9198    } else {
9199        None
9200    }
9201}
9202
9203fn language_icon_file(language: &str) -> Option<&'static str> {
9204    match language {
9205        "C" => Some("c.png"),
9206        "C++" => Some("cpp.png"),
9207        "C#" => Some("c-sharp.png"),
9208        "Python" => Some("python.png"),
9209        "Shell" => Some("shell.png"),
9210        "PowerShell" => Some("powershell.png"),
9211        "JavaScript" => Some("java-script.png"),
9212        "HTML" => Some("html-5.png"),
9213        "Java" => Some("java.png"),
9214        "Visual Basic" => Some("visual-basic.png"),
9215        "Assembly" => Some("asm.png"),
9216        "Go" => Some("go.png"),
9217        "R" => Some("r.png"),
9218        "XML" => Some("xml.png"),
9219        "Groovy" => Some("groovy.png"),
9220        "Dockerfile" => Some("docker.png"),
9221        "Makefile" => Some("makefile.svg"),
9222        "Perl" => Some("perl.svg"),
9223        _ => None,
9224    }
9225}
9226
9227// Inline SVG badges for languages that have no PNG icon in images/icons/.
9228// Using inline SVG keeps the web UI fully self-contained — no extra files
9229// needed on disk, no 404s on air-gapped deployments.
9230// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
9231fn language_inline_svg(language: &str) -> Option<&'static str> {
9232    match language {
9233        "Rust" => Some(
9234            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>"##,
9235        ),
9236        "TypeScript" => Some(
9237            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>"##,
9238        ),
9239        _ => None,
9240    }
9241}
9242
9243// The input is already lowercased via `to_ascii_lowercase()` before the
9244// `ends_with` calls, so these comparisons are inherently case-insensitive.
9245#[allow(clippy::case_sensitive_file_extension_comparisons)]
9246fn classify_preview_file(name: &str) -> PreviewKind {
9247    let lower = name.to_ascii_lowercase();
9248
9249    let scannable = [
9250        ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
9251        ".psm1", ".psd1",
9252    ]
9253    .iter()
9254    .any(|suffix| lower.ends_with(suffix));
9255
9256    if scannable {
9257        PreviewKind::Supported
9258    } else if lower.ends_with(".min.js")
9259        || lower.ends_with(".lock")
9260        || lower.ends_with(".png")
9261        || lower.ends_with(".jpg")
9262        || lower.ends_with(".jpeg")
9263        || lower.ends_with(".gif")
9264        || lower.ends_with(".zip")
9265        || lower.ends_with(".pdf")
9266        || lower.ends_with(".pyc")
9267        || lower.ends_with(".xz")
9268        || lower.ends_with(".tar")
9269        || lower.ends_with(".gz")
9270    {
9271        PreviewKind::Skipped
9272    } else {
9273        PreviewKind::Unsupported
9274    }
9275}
9276
9277fn preview_relative_path(root: &Path, path: &Path) -> String {
9278    path.strip_prefix(root)
9279        .ok()
9280        .unwrap_or(path)
9281        .to_string_lossy()
9282        .replace('\\', "/")
9283        .trim_matches('/')
9284        .to_string()
9285}
9286
9287fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
9288    if relative.is_empty() {
9289        return false;
9290    }
9291
9292    exclude_patterns.iter().any(|pattern| {
9293        wildcard_match(pattern, relative)
9294            || wildcard_match(pattern, &format!("{relative}/"))
9295            || wildcard_match(pattern, &format!("{relative}/placeholder"))
9296    })
9297}
9298
9299fn should_include_preview_file(
9300    relative: &str,
9301    include_patterns: &[String],
9302    exclude_patterns: &[String],
9303) -> bool {
9304    if relative.is_empty() {
9305        return true;
9306    }
9307
9308    let included = include_patterns.is_empty()
9309        || include_patterns
9310            .iter()
9311            .any(|pattern| wildcard_match(pattern, relative));
9312    let excluded = exclude_patterns
9313        .iter()
9314        .any(|pattern| wildcard_match(pattern, relative));
9315
9316    included && !excluded
9317}
9318
9319fn wildcard_match(pattern: &str, candidate: &str) -> bool {
9320    let pattern = pattern.trim().replace('\\', "/");
9321    let candidate = candidate.trim().replace('\\', "/");
9322    let p = pattern.as_bytes();
9323    let c = candidate.as_bytes();
9324    let mut pi = 0usize;
9325    let mut ci = 0usize;
9326    let mut star: Option<usize> = None;
9327    let mut star_match = 0usize;
9328
9329    while ci < c.len() {
9330        if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
9331            pi += 1;
9332            ci += 1;
9333        } else if pi < p.len() && p[pi] == b'*' {
9334            while pi < p.len() && p[pi] == b'*' {
9335                pi += 1;
9336            }
9337            star = Some(pi);
9338            star_match = ci;
9339        } else if let Some(star_pi) = star {
9340            star_match += 1;
9341            ci = star_match;
9342            pi = star_pi;
9343        } else {
9344            return false;
9345        }
9346    }
9347
9348    while pi < p.len() && p[pi] == b'*' {
9349        pi += 1;
9350    }
9351
9352    pi == p.len()
9353}
9354
9355fn escape_html(value: &str) -> String {
9356    value
9357        .replace('&', "&amp;")
9358        .replace('<', "&lt;")
9359        .replace('>', "&gt;")
9360        .replace('"', "&quot;")
9361        .replace('\'', "&#39;")
9362}
9363
9364#[derive(Clone)]
9365struct SubmoduleRow {
9366    name: String,
9367    relative_path: String,
9368    files_analyzed: u64,
9369    code_lines: u64,
9370    comment_lines: u64,
9371    blank_lines: u64,
9372    total_physical_lines: u64,
9373    html_url: Option<String>,
9374}
9375
9376#[derive(Template)]
9377#[template(
9378    source = r##"
9379<!doctype html>
9380<html lang="en">
9381<head>
9382  <meta charset="utf-8">
9383  <title>OxideSLOC | tmp-sloc</title>
9384  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
9385  <style nonce="{{ csp_nonce }}">
9386    :root {
9387      --bg: #efe9e2;
9388      --surface: #fcfaf7;
9389      --surface-2: #f7f0e8;
9390      --surface-3: #efe3d5;
9391      --line: #dfcfbf;
9392      --line-strong: #cfb29c;
9393      --text: #2f241c;
9394      --muted: #6f6257;
9395      --muted-2: #917f71;
9396      --nav: #b85d33;
9397      --nav-2: #7a371b;
9398      --accent: #2563eb;
9399      --accent-2: #1d4ed8;
9400      --oxide: #b85d33;
9401      --oxide-2: #8f4220;
9402      --success-bg: #eaf9ee;
9403      --success-text: #1c8746;
9404      --warn-bg: #fff2d8;
9405      --warn-text: #926000;
9406      --danger-bg: #fdeaea;
9407      --danger-text: #b33b3b;
9408      --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
9409      --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
9410      --radius: 14px;
9411    }
9412
9413    body.dark-theme {
9414      --bg: #1b1511;
9415      --surface: #261c17;
9416      --surface-2: #2d221d;
9417      --surface-3: #372922;
9418      --line: #524238;
9419      --line-strong: #6c5649;
9420      --text: #f5ece6;
9421      --muted: #c7b7aa;
9422      --muted-2: #aa9485;
9423      --nav: #b85d33;
9424      --nav-2: #7a371b;
9425      --accent: #6f9bff;
9426      --accent-2: #4a78ee;
9427      --oxide: #d37a4c;
9428      --oxide-2: #b35428;
9429      --success-bg: #163927;
9430      --success-text: #8fe2a8;
9431      --warn-bg: #3c2d11;
9432      --warn-text: #f3cb75;
9433      --danger-bg: #3d1f1f;
9434      --danger-text: #ff9f9f;
9435      --shadow: 0 14px 28px rgba(0,0,0,0.28);
9436      --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
9437    }
9438
9439    * { box-sizing: border-box; }
9440    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); }
9441    html { overflow-y: scroll; }
9442    body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
9443    .top-nav, .page, .loading { position: relative; z-index: 2; }
9444    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
9445    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
9446    .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); }
9447    .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; }
9448    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
9449    .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)); }
9450    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
9451    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
9452    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
9453    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
9454    .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; }
9455    .nav-project-pill.visible { display:inline-flex; }
9456    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
9457    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
9458    .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
9459    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
9460    @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; } }
9461    .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; }
9462    a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
9463    .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; }
9464    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
9465    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
9466    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
9467    .theme-toggle .icon-sun { display:none; }
9468    body.dark-theme .theme-toggle .icon-sun { display:block; }
9469    body.dark-theme .theme-toggle .icon-moon { display:none; }
9470    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
9471    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
9472    .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);}
9473    .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;}
9474    .settings-close:hover{color:var(--text);background:var(--surface-2);}
9475    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
9476    .settings-modal-body{padding:14px 16px 16px;}
9477    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
9478    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
9479    .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;}
9480    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
9481    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
9482    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
9483    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
9484    .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;}
9485    .tz-select:focus{border-color:var(--oxide);}
9486    .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; }
9487    .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;}
9488    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; flex: 1; width: 100%; }
9489    .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
9490    .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
9491    .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; }
9492    .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
9493    body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
9494    .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
9495    .wb-stats-header { padding: 10px 24px 0; }
9496    .wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
9497    .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
9498    .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; }
9499    .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9500    body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9501    .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9502    .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
9503    .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; }
9504    body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
9505    .ws-stat-analyzers { position: relative; }
9506    .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; }
9507    .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
9508    .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
9509    .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
9510    .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
9511    .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; }
9512    body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
9513    .ws-divider { display: none; }
9514    .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%; }
9515    .ws-path-link:hover { color:var(--oxide); }
9516    body.dark-theme .ws-path-link { color:var(--oxide); }
9517    .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
9518    .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9519    .ws-stat-clamp { max-width: 200px; overflow: hidden; }
9520    .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
9521    .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
9522    .ws-mini-box-sm .ws-mini-label { font-size:9px; }
9523    .ws-mini-box-sm .ws-mini-value { font-size:13px; }
9524    .ws-mini-box-lg { flex:2 1 0; }
9525    .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
9526    .ws-mini-box-br { flex:1.5 1 0; }
9527    .scope-legend-row { display:inline-flex; flex-direction:row; align-items:center; flex-wrap:wrap; gap:6px; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:13px; flex-shrink:0; border-left:3px solid var(--line-strong); }
9528    .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
9529    .path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
9530    .path-scope-grid > input[type=text] { width:100%; min-width:0; }
9531    .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; }
9532    .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
9533    .git-source-banner strong { font-weight:800; color:var(--text); }
9534    .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; }
9535    body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
9536    .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
9537    .git-source-banner a:hover { text-decoration:underline; }
9538    .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
9539    .path-scope-sep { background:var(--line); margin:4px 14px; }
9540    .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
9541    .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
9542    .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
9543    .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
9544    .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
9545    .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
9546    .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; }
9547    .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
9548    body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
9549    .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
9550    .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; }
9551    .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
9552    .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
9553    [data-wb-tip] { cursor:help; }
9554    .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
9555    .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
9556    .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; }
9557    .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
9558    .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
9559    body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
9560    .summary-card, .card, .step-nav, .explainer-card, .review-card, .workspace-card, .artifact-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; }
9561    .summary-card:hover, .workspace-card:hover, .explainer-card:hover, .artifact-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
9562    .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
9563    .side-info-card { padding: 18px; }
9564    .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
9565    .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
9566    .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
9567    .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
9568    .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); }
9569    .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
9570    .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9571    .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
9572    .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; }
9573    .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:start; min-height: calc(100vh - 57px); }
9574    .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; }
9575    .side-stack::-webkit-scrollbar { display: none; }
9576    .step-nav { padding: 20px 16px; }
9577    .step-nav h3 { margin: 6px 4px 20px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
9578    .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; }
9579    .step-button:hover { background: var(--surface-2); }
9580    .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); }
9581    .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; }
9582    .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
9583    .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
9584    .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
9585    .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); }
9586    .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
9587    .step-nav-sum-row:last-child { border-bottom:none; }
9588    .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
9589    .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; }
9590    .step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
9591    .quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
9592    .quick-scan-section { padding: 10px 4px 14px; }
9593    .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
9594    .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; }
9595    .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
9596    .quick-scan-btn:active { transform:translateY(0); }
9597    .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
9598    .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
9599    .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
9600    @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);} }
9601    @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
9602    .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
9603    .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
9604    .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
9605    .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
9606    .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
9607    .step-button.done .step-check { opacity:1; }
9608    .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
9609    .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; }
9610    .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; }
9611    .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; }
9612    body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
9613    .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
9614    .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
9615    .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
9616    .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
9617    .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
9618    .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
9619    .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
9620    .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
9621    .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
9622    .card-body { padding: 22px; }
9623    .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
9624    .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
9625    @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
9626    .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
9627    .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
9628    .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
9629    .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
9630    .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
9631    .field { min-width:0; }
9632    label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
9633    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; }
9634    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); }
9635    input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
9636    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); }
9637    textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
9638    .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
9639    .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; }
9640    .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
9641    .path-history-badge.new   { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
9642    .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
9643    body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
9644    .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
9645    .input-group.compact { grid-template-columns: 1fr auto auto; }
9646    .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
9647    .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)); }
9648    .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
9649    .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
9650    .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
9651    .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
9652    .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; }
9653    .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
9654    .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; }
9655    .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); }
9656    .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
9657    .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
9658    button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
9659    button.secondary { background: var(--surface); }
9660    button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9661    button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
9662    button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
9663    button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
9664    .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); }
9665    .section + .wizard-actions { border-top: none; padding-top: 0; }
9666    .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
9667    .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9668    .field-help-grid.coupled-help { margin-top: 12px; }
9669    .field-help-grid.preset-grid { align-items: start; }
9670    .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
9671    .preset-inline-row .field { margin: 0; }
9672    .preset-inline-row .explainer-card { margin: 0; }
9673    .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
9674    .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
9675    .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
9676    .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
9677    .preset-kv-row > :last-child { flex:1; min-width:0; }
9678    .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
9679    .output-field-row .field { margin: 0; }
9680    .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; }
9681    .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
9682    .step3-subtitle { margin-bottom: 10px; max-width: none; }
9683    .counting-intro { margin-bottom: 8px; max-width: none; }
9684    .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; }
9685    .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
9686    .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
9687    .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; }
9688    .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; }
9689    .section-spacer-top { margin-top: 28px; }
9690    .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
9691    .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
9692    .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
9693    .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); }
9694    .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
9695    .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; }
9696    .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; }
9697    .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
9698    .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9699    .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
9700    .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
9701    .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
9702    .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
9703    .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
9704    .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
9705    .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
9706    .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
9707    .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
9708    .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
9709    .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); }
9710    .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
9711    .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
9712    .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; }
9713    .docstring-example-inset .field-help-title { margin-bottom: 6px; }
9714    .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; }
9715    .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; }
9716    .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
9717    .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
9718    .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
9719    .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
9720    .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
9721    .advanced-rule-description strong { color: var(--text); }
9722    .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
9723    .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
9724    .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
9725    .review-link:hover { text-decoration: underline; }
9726    .artifact-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
9727    .artifact-card { position:relative; padding: 16px; cursor:pointer; }
9728    .artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
9729    .artifact-card .marker { position:absolute; top: 12px; right: 12px; width: 22px; height: 22px; border-radius: 999px; border:2px solid var(--line-strong); display:flex; align-items:center; justify-content:center; font-size: 12px; color: transparent; }
9730    .artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
9731    .artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
9732    .artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
9733    body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
9734    .artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
9735    body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
9736    .artifact-icon { width: 42px; height: 42px; border-radius: 12px; background: var(--surface-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size: 22px; font-weight: 900; }
9737    .artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
9738    .artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
9739    .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
9740    .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
9741    .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
9742    .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
9743    .review-card h4 { margin: 0 0 8px; font-size: 17px; }
9744    .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
9745    .review-card ul { padding-left: 18px; margin: 0; }
9746    .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
9747    .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
9748    .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
9749    .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
9750    .review-card { min-height: 200px; }
9751    .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
9752    .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
9753    .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
9754    .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
9755    .lang-overflow-chip { position:relative; cursor:default; }
9756    .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; }
9757    .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
9758    .git-inline-row { align-items:start; }
9759    .mixed-line-card { display:flex; flex-direction:column; }
9760    .preset-inline-row .toggle-card { justify-content: center; }
9761        .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
9762    .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
9763    .explorer-toolbar.compact { padding: 0; border-bottom: none; }
9764    .explorer-title { font-size: 18px; font-weight: 850; }
9765    .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
9766    .explorer-subtitle.wide { max-width: none; }
9767    .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
9768    .better-spacing { align-items:flex-start; justify-content:flex-end; }
9769    .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; }
9770    .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
9771    .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
9772    .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
9773    .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
9774    body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
9775    .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
9776    .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; }
9777    .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
9778    .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
9779    .scope-stat-button.supported { background: var(--success-bg); }
9780    .scope-stat-button.skipped { background: var(--warn-bg); }
9781    .scope-stat-button.unsupported { background: var(--danger-bg); }
9782    .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
9783    .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
9784    .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
9785    [data-tooltip] { position: relative; }
9786    [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); }
9787    [data-tooltip]:hover::after { display: block; }
9788    .scope-stat-button[data-tooltip] { cursor: pointer; }
9789    .badge[data-tooltip] { cursor: help; }
9790    .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
9791    .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
9792    .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
9793    .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; }
9794    .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; }
9795    code { display:inline-block; margin-top:0; padding:2px 7px; }
9796    .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
9797    .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
9798    .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
9799    .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
9800    .language-pill.muted-pill { color: var(--muted); }
9801    button.language-pill { appearance:none; cursor:pointer; }
9802    .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); }
9803    .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
9804    .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; }
9805    .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
9806    .file-explorer-search-row { margin-left: auto; }
9807    .explorer-filter-select { min-width: 170px; width: 170px; }
9808    .explorer-search { min-width: 300px; width: 300px; }
9809    .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); }
9810    .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; }
9811    .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
9812    .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
9813    .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
9814    .file-explorer-tree { max-height: 640px; overflow:auto; }
9815    .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); }
9816    .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
9817    body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
9818    .tree-row.hidden-by-filter { display:none !important; }
9819    .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
9820    .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; }
9821    .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; }
9822    .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
9823    .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
9824    .tree-node { display:inline-flex; align-items:center; min-width:0; }
9825    .tree-node-dir { color: var(--text); font-weight: 800; }
9826    .tree-node-supported { color: var(--success-text); }
9827    .tree-node-skipped { color: var(--warn-text); }
9828    .tree-node-unsupported { color: var(--danger-text); }
9829    .tree-node-more { color: var(--muted-2); font-style: italic; }
9830    .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
9831    .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
9832    .tree-status-cell { display:flex; justify-content:flex-start; }
9833    .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
9834    .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; }
9835    .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
9836    .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
9837    .cov-scan-idle { display:none; }
9838    .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
9839    .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
9840    .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
9841    .cov-scan-title { font-weight:600; font-size:12.5px; }
9842    .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
9843    .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
9844    .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; }
9845    .cov-scan-use:hover { opacity:.75; }
9846    .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; }
9847    .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; }
9848    @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
9849    .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
9850    .cov-scan-scanning .cov-scan-title { color:var(--muted); }
9851    .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
9852    .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
9853    .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
9854    .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
9855    .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
9856    body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
9857    body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
9858    body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
9859    body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
9860    .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
9861    body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
9862    .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
9863    .cov-scan-hint .cov-scan-title { color:#7a5e00; }
9864    .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
9865    .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
9866    body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
9867    body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
9868    body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
9869    body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
9870    .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
9871    .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
9872    .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); }
9873    .loading.active { display:flex; }
9874    .loading-card { width: min(730px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 48px rgba(0,0,0,0.22); padding: 36px 42px; }
9875    .progress-bar { width:100%; height:6px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
9876    .progress-bar span { display:block; width:42%; height:100%; background: linear-gradient(90deg, var(--accent-2), var(--oxide,#d37a4c)); animation: pulseBar 1.6s ease-in-out infinite; }
9877    @keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
9878    .lc-badge { display:inline-flex;align-items:center;gap:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.28);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:16px; }
9879    .lc-dot { width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:lcPulse 1.4s ease-in-out infinite;flex:0 0 auto; }
9880    @keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
9881    .lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
9882    .lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
9883    .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px 14px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:16px; }
9884    .lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
9885    .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
9886    .lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
9887    .lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
9888    .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; }
9889    .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; }
9890    .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
9891    .lc-err p { margin:0;font-size:12px;color:var(--muted); }
9892    .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; }
9893    .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
9894    .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
9895    .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; }
9896    .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
9897    .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
9898    .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; }
9899    .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
9900    .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
9901    .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
9902    .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
9903    body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
9904    body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
9905    .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; }
9906    .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
9907    body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
9908    .hidden { display:none !important; }
9909    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
9910    .site-footer a{color:var(--muted);}
9911    @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
9912    @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; } }
9913    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
9914    @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));}}
9915    .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;}
9916    .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; }
9917    .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
9918    .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
9919    .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
9920    .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; }
9921    .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
9922    .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
9923    .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 .18s ease; z-index:300; }
9924    .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
9925    .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
9926    .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; }
9927    .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
9928    .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
9929    .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; }
9930    .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
9931    .info-icon-btn:hover { color:var(--text); }
9932    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); }
9933    body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
9934    body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
9935  </style>
9936</head>
9937<body>
9938  <div class="background-watermarks" aria-hidden="true">
9939    <img src="/images/logo/logo-text.png" alt="" />
9940    <img src="/images/logo/logo-text.png" alt="" />
9941    <img src="/images/logo/logo-text.png" alt="" />
9942    <img src="/images/logo/logo-text.png" alt="" />
9943    <img src="/images/logo/logo-text.png" alt="" />
9944    <img src="/images/logo/logo-text.png" alt="" />
9945    <img src="/images/logo/logo-text.png" alt="" />
9946    <img src="/images/logo/logo-text.png" alt="" />
9947    <img src="/images/logo/logo-text.png" alt="" />
9948    <img src="/images/logo/logo-text.png" alt="" />
9949    <img src="/images/logo/logo-text.png" alt="" />
9950    <img src="/images/logo/logo-text.png" alt="" />
9951    <img src="/images/logo/logo-text.png" alt="" />
9952    <img src="/images/logo/logo-text.png" alt="" />
9953  </div>
9954  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
9955  <div class="top-nav">
9956    <div class="top-nav-inner">
9957      <a class="brand" href="/">
9958        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
9959        <div class="brand-copy">
9960          <div class="brand-title">OxideSLOC</div>
9961          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
9962        </div>
9963      </a>
9964      <div class="nav-project-slot">
9965        <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
9966          <span class="nav-project-label">Project</span>
9967          <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
9968        </div>
9969      </div>
9970      <div class="nav-status">
9971        <a class="nav-pill" href="/">Home</a>
9972        <div class="nav-dropdown">
9973          <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>
9974          <div class="nav-dropdown-menu">
9975            <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>
9976          </div>
9977        </div>
9978        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
9979        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
9980        <div class="nav-dropdown">
9981          <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>
9982          <div class="nav-dropdown-menu">
9983            <a href="/webhook-setup"><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>
9984          </div>
9985        </div>
9986        <div class="server-status-wrap">
9987          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
9988          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
9989        </div>
9990        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
9991          <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>
9992        </button>
9993        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
9994          <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>
9995          <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>
9996        </button>
9997      </div>
9998    </div>
9999  </div>
10000
10001  <div class="loading" id="loading">
10002    <div class="loading-card">
10003      <div class="lc-badge" id="lc-badge"><span class="lc-dot"></span>Analysis running</div>
10004      <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
10005      <p class="lc-sub">Results are saved automatically — you can leave this page.</p>
10006      <div class="lc-path" id="lc-path"></div>
10007      <div class="lc-metrics" id="lc-metrics">
10008        <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
10009        <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
10010      </div>
10011      <div class="progress-bar" id="lc-progress-bar"><span></span></div>
10012      <div class="lc-warn hidden" id="lc-warn">This is taking longer than usual. Large repositories can take several minutes — the analysis is still running.</div>
10013      <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>
10014      <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
10015      <div class="lc-actions hidden" id="lc-actions">
10016        <button class="primary" id="lc-dismiss" type="button">Try Again</button>
10017        <a href="/view-reports" class="lc-outline-btn">View Reports</a>
10018      </div>
10019      <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
10020        <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>
10021        Cancel scan
10022      </button>
10023    </div>
10024  </div>
10025
10026  <div class="page">
10027    <div class="workbench-strip">
10028      <div class="workbench-box wb-stats">
10029        <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
10030          <span class="wb-stats-title">Analysis session</span>
10031        </div>
10032        <div class="ws-left">
10033          <div class="ws-stat ws-stat-analyzers">
10034            <span class="ws-label">Analyzers</span>
10035            <span class="ws-value">
10036              <span class="ws-badge">41 languages</span>
10037            </span>
10038            <div class="ws-lang-tooltip">
10039              <div class="ws-lang-tooltip-hdr">41 supported languages</div>
10040              <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>
10041              <div class="ws-lang-grid">
10042                <span class="ws-lang-item">Assembly</span>
10043                <span class="ws-lang-item">C</span>
10044                <span class="ws-lang-item">C++</span>
10045                <span class="ws-lang-item">C#</span>
10046                <span class="ws-lang-item">Clojure</span>
10047                <span class="ws-lang-item">CSS</span>
10048                <span class="ws-lang-item">Dart</span>
10049                <span class="ws-lang-item">Dockerfile</span>
10050                <span class="ws-lang-item">Elixir</span>
10051                <span class="ws-lang-item">Erlang</span>
10052                <span class="ws-lang-item">F#</span>
10053                <span class="ws-lang-item">Go</span>
10054                <span class="ws-lang-item">Groovy</span>
10055                <span class="ws-lang-item">Haskell</span>
10056                <span class="ws-lang-item">HTML</span>
10057                <span class="ws-lang-item">Java</span>
10058                <span class="ws-lang-item">JavaScript</span>
10059                <span class="ws-lang-item">Julia</span>
10060                <span class="ws-lang-item">Kotlin</span>
10061                <span class="ws-lang-item">Lua</span>
10062                <span class="ws-lang-item">Makefile</span>
10063                <span class="ws-lang-item">Nim</span>
10064                <span class="ws-lang-item">Obj-C</span>
10065                <span class="ws-lang-item">OCaml</span>
10066                <span class="ws-lang-item">Perl</span>
10067                <span class="ws-lang-item">PHP</span>
10068                <span class="ws-lang-item">PowerShell</span>
10069                <span class="ws-lang-item">Python</span>
10070                <span class="ws-lang-item">R</span>
10071                <span class="ws-lang-item">Ruby</span>
10072                <span class="ws-lang-item">Rust</span>
10073                <span class="ws-lang-item">Scala</span>
10074                <span class="ws-lang-item">SCSS</span>
10075                <span class="ws-lang-item">Shell</span>
10076                <span class="ws-lang-item">SQL</span>
10077                <span class="ws-lang-item">Svelte</span>
10078                <span class="ws-lang-item">Swift</span>
10079                <span class="ws-lang-item">TypeScript</span>
10080                <span class="ws-lang-item">Vue</span>
10081                <span class="ws-lang-item">XML</span>
10082                <span class="ws-lang-item">Zig</span>
10083              </div>
10084            </div>
10085          </div>
10086          <div class="ws-divider"></div>
10087          <div class="ws-stat" data-wb-tip="Localhost mode — all scans run on this machine against local file system paths."><span class="ws-label">Mode</span><span class="ws-value">Localhost</span></div>
10088          <div class="ws-divider"></div>
10089          <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>
10090          <div class="ws-divider"></div>
10091          <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.">
10092            <span class="ws-label">Output</span>
10093            <span class="ws-value">
10094              <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
10095                <span id="ws-output-root">project/sloc</span>
10096              </button>
10097            </span>
10098          </div>
10099        </div>
10100      </div>
10101      <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.">
10102        <div class="ws-history-label">Scan history</div>
10103        <div class="ws-history-inner">
10104          <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
10105            <div class="ws-mini-label">Scans</div>
10106            <div class="ws-mini-value" id="ws-scan-count">—</div>
10107          </div>
10108          <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
10109            <div class="ws-mini-label">Last Scan</div>
10110            <div class="ws-mini-value" id="ws-last-scan">—</div>
10111          </div>
10112          <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
10113            <div class="ws-mini-label">Branch</div>
10114            <div class="ws-mini-value" id="ws-branch">—</div>
10115          </div>
10116        </div>
10117      </div>
10118    </div>
10119
10120    <div class="layout">
10121      <aside class="side-stack">
10122        <section class="step-nav">
10123        <h3>Guided scan setup</h3>
10124        <button type="button" class="step-button active" 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>
10125        <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>
10126        <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>
10127        <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>
10128
10129        <div class="step-steps-divider"></div>
10130
10131        <div class="step-nav-info" id="step-nav-info">
10132          <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
10133          <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>
10134        </div>
10135
10136        <div class="step-nav-summary" id="sidebar-summary" style="display:none">
10137          <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>
10138          <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>
10139          <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>
10140        </div>
10141
10142        <div class="quick-scan-divider"></div>
10143        <div class="quick-scan-section">
10144          <div class="quick-scan-label">No customization needed?</div>
10145          <button type="button" id="quick-scan-btn" class="quick-scan-btn">
10146            <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>
10147            Quick Scan
10148          </button>
10149          <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
10150        </div>
10151
10152        <div class="sidebar-kbd-hint"><span class="sidebar-kbd-key">←</span><span>Back</span><span style="margin:0 6px;">·</span><span class="sidebar-kbd-key">→</span><span>Next</span></div>
10153        </section>
10154
10155      </aside>
10156
10157      <section class="card">
10158        <div class="card-header">
10159          <div class="card-title-row">
10160            <div>
10161              <h1 class="card-title">Guided scan configuration</h1>
10162              <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
10163            </div>
10164            <div class="wizard-progress" aria-label="Scan setup progress">
10165              <div class="wizard-progress-top">
10166                <span class="wizard-progress-label">Setup progress</span>
10167                <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
10168              </div>
10169              <div class="wizard-progress-track">
10170                <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
10171              </div>
10172            </div>
10173          </div>
10174        </div>
10175        <div class="card-body">
10176          <form method="post" action="/analyze" id="analyze-form">
10177            <div class="wizard-step active" data-step="1">
10178              <div class="section">
10179                <div class="section-kicker">Step 1</div>
10180                <h2>Select project and preview scope</h2>
10181                <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
10182                <div class="field">
10183                  <label for="path">Project path</label>
10184                  {% if !git_repo.is_empty() %}
10185                  <div class="git-source-banner">
10186                    <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>
10187                    Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
10188                    <a href="/git-browser">← Back to Git Browser</a>
10189                  </div>
10190                  {% endif %}
10191                  <div class="path-scope-grid">
10192                      {% if !git_repo.is_empty() %}
10193                      <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
10194                      <input type="hidden" name="git_repo" value="{{ git_repo }}" />
10195                      <input type="hidden" name="git_ref" value="{{ git_ref }}" />
10196                      {% else %}
10197                      <input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
10198                      <button type="button" class="mini-button oxide" id="browse-path">Browse</button>
10199                      <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
10200                      {% endif %}
10201                    <div class="path-scope-sep"></div>
10202                    <div class="scope-legend-row">
10203                      <span class="scope-legend-label">Scope legend:</span>
10204                      <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
10205                      <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
10206                      <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
10207                    </div>
10208                  </div>
10209                  {% if git_repo.is_empty() %}
10210                  <div class="path-info-row">
10211                    <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
10212                      <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>
10213                      <span id="project-size-text">Project size: —</span>
10214                    </button>
10215                  </div>
10216                  {% else %}
10217                  <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
10218                  {% endif %}
10219                  <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
10220                  <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
10221                </div>
10222
10223                <div class="scope-preview-divider" aria-hidden="true"></div>
10224
10225                <div id="preview-panel">
10226                  <div class="preview-error">Loading preview...</div>
10227                </div>
10228              </div>
10229
10230              <div class="section" style="margin-top:14px;">
10231                <div class="preset-inline-row git-inline-row">
10232                  <div class="toggle-card" style="margin:0;">
10233                    <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
10234                    <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
10235                    <label class="checkbox">
10236                      <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
10237                      <div>
10238                        <span>Detect and separate git submodules</span>
10239                        <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
10240                      </div>
10241                    </label>
10242                  </div>
10243                  <div class="explainer-card prominent" style="margin:0;">
10244                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10245                    <div class="advanced-rule-description"><strong>Purpose:</strong> Group each git submodule&#39;s files into its own section in the report so you can see per-submodule SLOC totals alongside overall figures.<br /><strong>Good default when:</strong> your repository contains nested sub-projects managed as git submodules.<br /><strong>Turn it off when:</strong> the repository has no submodules, or you only need aggregate totals across the whole tree.</div>
10246                    <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
10247    path = libs/core
10248    url  = https://github.com/org/core.git
10249
10250[submodule "libs/ui"]
10251    path = libs/ui
10252    url  = https://github.com/org/ui.git</div>
10253                  </div>
10254                </div>
10255              </div>
10256
10257              <div class="section">
10258                <div class="field-grid">
10259                  <div class="field">
10260                    <label for="include_globs">Include globs</label>
10261                    <textarea id="include_globs" name="include_globs" placeholder="examples:&#10;src/**/*.py&#10;scripts/*.sh"></textarea>
10262                    <div class="hint">Use line-separated or comma-separated patterns when you want to narrow the scan to only certain folders or file types. If you leave this empty, everything under the project path is eligible first, and then exclude rules trim it down.</div>
10263                  </div>
10264                  <div class="field">
10265                    <label for="exclude_globs">Exclude globs</label>
10266                    <textarea id="exclude_globs" name="exclude_globs" placeholder="examples:&#10;vendor/**&#10;**/*.min.js"></textarea>
10267                    <div id="quick-exclude-chips" class="quick-excl-row">
10268                      <span class="quick-excl-label">Quick add:</span>
10269                      <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
10270                      <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
10271                      <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
10272                      <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
10273                      <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
10274                      <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>
10275                    </div>
10276                    <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>
10277                  </div>
10278                </div>
10279                <div class="glob-guidance-grid">
10280                  <div class="glob-guidance-card">
10281                    <strong>How to read them</strong>
10282                    <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>
10283                  </div>
10284                  <div class="glob-guidance-card">
10285                    <strong>Common include examples</strong>
10286                    <p><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <code>tests/**</code> everything under tests.</p>
10287                  </div>
10288                  <div class="glob-guidance-card">
10289                    <strong>Common exclude examples</strong>
10290                    <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
10291                  </div>
10292                </div>
10293              </div>
10294
10295              <div class="section" style="margin-top:14px;">
10296                <div class="preset-inline-row git-inline-row">
10297                  <div class="toggle-card" style="margin:0;">
10298                    <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
10299                    <h4 style="margin:0 0 12px;font-size:16px;">Code Coverage file <span style="font-weight:400;color:var(--muted);font-size:13px;">(optional)</span></h4>
10300                    <div class="field" style="margin:0;">
10301                      <div class="input-group compact">
10302                        <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
10303                        <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
10304                      </div>
10305                      <div class="hint" style="margin-top:8px;">When provided, line, function, and branch coverage percentages are overlaid on each file in the report and shown on the Test Metrics page.</div>
10306                      <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
10307                    </div>
10308                  </div>
10309                  <div class="explainer-card prominent" style="margin:0;">
10310                    <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
10311                    <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>
10312                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
10313lcov --capture --directory . --output-file coverage/lcov.info
10314
10315# C / C++ — llvm-cov (LCOV)
10316llvm-profdata merge -sparse default.profraw -o default.profdata
10317llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
10318
10319# C# — coverlet (Cobertura XML)
10320dotnet test --collect:"XPlat Code Coverage"
10321
10322# Python — pytest-cov (Cobertura XML)
10323pytest --cov --cov-report=xml
10324
10325# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
10326./gradlew jacocoTestReport</div>
10327                  </div>
10328                </div>
10329              </div>
10330
10331              <div class="wizard-actions">
10332                <div class="left"></div>
10333                <div class="right">
10334                  <button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
10335                </div>
10336              </div>
10337            </div>
10338
10339            <div class="wizard-step" data-step="2">
10340              <div class="section">
10341                <div class="section-kicker">Step 2</div>
10342                <h2>Choose counting behavior</h2>
10343                <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>
10344                <div class="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
10345                <div class="subsection-bar">Primary line classification</div>
10346                <div class="preset-kv-row">
10347                  <div class="toggle-card mixed-line-card" style="margin:0;">
10348                    <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
10349                    <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
10350                    <select id="mixed_line_policy" name="mixed_line_policy">
10351                      <option value="code_only">Code only</option>
10352                      <option value="code_and_comment">Code and comment</option>
10353                      <option value="comment_only">Comment only</option>
10354                      <option value="separate_mixed_category">Separate mixed category</option>
10355                    </select>
10356                    <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
10357                  </div>
10358                  <div class="explainer-card prominent" style="margin:0;">
10359                    <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
10360                    <div class="explainer-body" id="mixed-policy-description"></div>
10361                    <div class="code-sample" id="mixed-policy-example"></div>
10362                  </div>
10363                </div>
10364              </div>
10365
10366              <div class="subsection-bar">Additional scan rules</div>
10367              <div class="scan-rules-grid">
10368                <div class="preset-inline-row">
10369                  <div class="toggle-card" style="margin:0;">
10370                    <div class="field-help-title">Generated files</div>
10371                    <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
10372                    <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10373                  </div>
10374                  <div class="explainer-card prominent" style="margin:0;">
10375                    <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>
10376                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
10377# Files matching codegen patterns are excluded:
10378#   *.generated.cs  *.pb.go  *.g.dart</div>
10379                  </div>
10380                </div>
10381                <div class="preset-inline-row">
10382                  <div class="toggle-card" style="margin:0;">
10383                    <div class="field-help-title">Minified files</div>
10384                    <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
10385                    <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10386                  </div>
10387                  <div class="explainer-card prominent" style="margin:0;">
10388                    <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>
10389                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
10390# Heuristic: very long lines + low whitespace ratio
10391#   jquery.min.js  bundle.min.css  → skipped</div>
10392                  </div>
10393                </div>
10394                <div class="preset-inline-row">
10395                  <div class="toggle-card" style="margin:0;">
10396                    <div class="field-help-title">Vendor directories</div>
10397                    <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
10398                    <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
10399                  </div>
10400                  <div class="explainer-card prominent" style="margin:0;">
10401                    <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>
10402                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
10403# Directories named vendor/ node_modules/ third_party/
10404#   → entire subtree is excluded from totals</div>
10405                  </div>
10406                </div>
10407                <div class="preset-inline-row">
10408                  <div class="toggle-card" style="margin:0;">
10409                    <div class="field-help-title">Lockfiles and manifests</div>
10410                    <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
10411                    <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
10412                  </div>
10413                  <div class="explainer-card prominent" style="margin:0;">
10414                    <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>
10415                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false  (default)
10416# Files like package-lock.json  Cargo.lock  yarn.lock
10417#   → skipped unless this is enabled</div>
10418                  </div>
10419                </div>
10420                <div class="preset-inline-row">
10421                  <div class="toggle-card" style="margin:0;">
10422                    <div class="field-help-title">Binary handling</div>
10423                    <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
10424                    <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>
10425                  </div>
10426                  <div class="explainer-card prominent" style="margin:0;">
10427                    <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>
10428                    <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip"  (default)
10429# Detected via long lines + low whitespace heuristic
10430#   .png  .exe  .so  → skipped silently</div>
10431                  </div>
10432                </div>
10433                <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
10434                  <div class="toggle-card" style="margin:0;">
10435                    <div class="field-help-title">Python docstrings</div>
10436                    <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
10437                    <label class="checkbox">
10438                      <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
10439                      <span>Count as comment-style lines</span>
10440                    </label>
10441                  </div>
10442                  <div class="explainer-card prominent" style="margin:0;">
10443                    <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>
10444                    <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
10445                  </div>
10446                </div>
10447              </div>
10448              <div class="always-tracked-tip">
10449                <div class="always-tracked-tip-icon">ℹ</div>
10450                <div class="always-tracked-tip-body">
10451                  <div class="field-help-title">Always tracked — not configurable &nbsp;·&nbsp; What these settings change</div>
10452                  <h4>Comment and blank-line basics &amp; Lines on the boundary</h4>
10453                  <div class="advanced-rule-description">Pure comment lines, multi-line comment blocks, blank lines, and total physical lines are always included by every supported analyzer. The settings on this page only affect lines that live on the boundary between code and comments — for example <code style="font-size:12px;">x = 1  # counter</code>, which contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
10454                </div>
10455              </div>
10456
10457              <div class="wizard-actions">
10458                <div class="left">
10459                  <button type="button" class="secondary prev-step" data-prev="1">Back</button>
10460                </div>
10461                <div class="right">
10462                  <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
10463                </div>
10464              </div>
10465            </div>
10466
10467            <div class="wizard-step" data-step="3">
10468              <div class="section">
10469                <div class="section-kicker">Step 3</div>
10470                <h2>Output and report identity</h2>
10471                <p class="card-subtitle step3-subtitle" style="white-space:nowrap;">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
10472                <div class="preset-kv-row">
10473                  <div class="toggle-card" style="margin:0;">
10474                    <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
10475                    <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
10476                    <select id="scan_preset">
10477                      <option value="balanced">Balanced local scan</option>
10478                      <option value="code_focused">Code focused</option>
10479                      <option value="comment_audit">Comment audit</option>
10480                      <option value="deep_review">Deep review</option>
10481                    </select>
10482                    <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
10483                  </div>
10484                  <div class="explainer-card">
10485                    <div class="field-help-title">Selected scan preset</div>
10486                    <div class="explainer-body" id="scan-preset-description"></div>
10487                    <div class="preset-summary-row" id="scan-preset-summary"></div>
10488                    <div class="code-sample" id="scan-preset-example"></div>
10489                    <div class="preset-note" id="scan-preset-note"></div>
10490                  </div>
10491                </div>
10492                <hr class="step3-separator" />
10493                <div class="preset-kv-row">
10494                  <div class="toggle-card" style="margin:0;">
10495                    <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
10496                    <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
10497                    <select id="artifact_preset">
10498                      <option value="review">Review bundle</option>
10499                      <option value="full">Full bundle</option>
10500                      <option value="html_only">HTML only</option>
10501                      <option value="machine">Machine bundle</option>
10502                    </select>
10503                    <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
10504                  </div>
10505                  <div class="explainer-card">
10506                    <div class="field-help-title">Selected artifact preset</div>
10507                    <div class="explainer-body" id="artifact-preset-description"></div>
10508                    <div class="preset-summary-row" id="artifact-preset-summary"></div>
10509                    <div class="code-sample" id="artifact-preset-example"></div>
10510                  </div>
10511                </div>
10512              </div>
10513
10514              <div class="section section-spacer-top">
10515                <div class="output-field-row">
10516                  <div class="field">
10517                    <label for="output_dir">Output directory</label>
10518                    <div class="input-group compact">
10519                      <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
10520                      <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
10521                      <button type="button" class="mini-button" id="use-default-output">Use default</button>
10522                    </div>
10523                    <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
10524                  </div>
10525                  <div class="output-field-aside">
10526                    <strong>Where reports land</strong>
10527                    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.
10528                  </div>
10529                </div>
10530              </div>
10531
10532              <div class="section section-spacer-top">
10533                <div class="output-field-row">
10534                  <div class="field">
10535                    <label for="report_title">Report title</label>
10536                    <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
10537                    <div class="hint">Appears in HTML and PDF output headers.</div>
10538                  </div>
10539                  <div class="output-field-aside">
10540                    <strong>Shown in exported artifacts</strong>
10541                    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.
10542                  </div>
10543                </div>
10544              </div>
10545
10546              <div class="section section-spacer-top">
10547                <div class="output-field-row">
10548                  <div class="field">
10549                    <label for="report_header_footer">Report header / footer</label>
10550                    <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
10551                    <div class="hint" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Printed on every HTML/PDF page — company name, project ID, or scanner tag.</div>
10552                  </div>
10553                  <div class="output-field-aside">
10554                    <strong>Page-level identification</strong>
10555                    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.
10556                  </div>
10557                </div>
10558              </div>
10559
10560              <div class="section">
10561                <div class="section-kicker">Artifacts</div>
10562                <div class="artifact-grid" style="margin-bottom:24px;">
10563                  <div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
10564                    <div class="marker">✓</div>
10565                    <div class="artifact-icon">H</div>
10566                    <h4>HTML report</h4>
10567                    <p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
10568                    <div class="artifact-tags">
10569                      <span class="soft-chip">Best for visual review</span>
10570                      <span class="soft-chip">Embeddable preview</span>
10571                    </div>
10572                    <input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
10573                  </div>
10574                  <div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
10575                    <div class="marker">✓</div>
10576                    <div class="artifact-icon">P</div>
10577                    <h4>PDF export</h4>
10578                    <p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
10579                    <div class="artifact-tags">
10580                      <span class="soft-chip">Portable snapshot</span>
10581                      <span class="soft-chip">Good for handoff</span>
10582                    </div>
10583                    <input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
10584                  </div>
10585                  <div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
10586                    <div style="position:absolute;inset:0;border-radius:inherit;background:radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.13) 100%);pointer-events:none;z-index:2;"></div>
10587                    <div class="marker">✓</div>
10588                    <div class="artifact-icon" style="color:var(--muted);">J</div>
10589                    <h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
10590                    <p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
10591                    <div class="artifact-tags">
10592                      <span class="soft-chip">Required for compare</span>
10593                      <span class="soft-chip">Auto-enabled</span>
10594                    </div>
10595                    <input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
10596                  </div>
10597                </div>
10598                <div class="hint" style="margin-top:12px;">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</div>
10599              </div>
10600
10601              <div class="wizard-actions">
10602                <div class="left">
10603                  <button type="button" class="secondary prev-step" data-prev="2">Back</button>
10604                </div>
10605                <div class="right">
10606                  <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
10607                </div>
10608              </div>
10609            </div>
10610
10611            <div class="wizard-step" data-step="4">
10612              <div class="section">
10613                <div class="section-kicker">Step 4</div>
10614                <h2>Review selections and run</h2>
10615                <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
10616                <div class="review-grid">
10617                  <div class="review-card highlight">
10618                    <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>
10619                    <ul id="review-scan-summary"></ul>
10620                  </div>
10621                  <div class="review-card highlight">
10622                    <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>
10623                    <ul id="review-count-summary"></ul>
10624                  </div>
10625                  <div class="review-card">
10626                    <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>
10627                    <ul id="review-artifact-summary"></ul>
10628                    <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
10629                  </div>
10630                  <div class="review-card">
10631                    <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>
10632                    <ul id="review-preview-summary"></ul>
10633                  </div>
10634                </div>
10635              </div>
10636
10637              <div class="wizard-actions">
10638                <div class="left">
10639                  <button type="button" class="secondary prev-step" data-prev="3">Back</button>
10640                </div>
10641                <div class="right">
10642                  <button type="submit" id="submit-button" class="primary">Run analysis</button>
10643                </div>
10644              </div>
10645            </div></form>
10646        </div>
10647      </section>
10648    </div>
10649  </div>
10650
10651  <script nonce="{{ csp_nonce }}">
10652    (function () {
10653      function startScanPhase() {
10654        var phaseEl = document.getElementById("scan-phase");
10655        if (!phaseEl) return;
10656        var phases = [
10657          "Discovering files...",
10658          "Decoding file encodings...",
10659          "Detecting languages...",
10660          "Analyzing source lines...",
10661          "Applying counting policies...",
10662          "Aggregating results...",
10663          "Rendering report..."
10664        ];
10665        var durations = [800, 600, 1200, 3000, 1000, 800, 600];
10666        var i = 0;
10667        function next() {
10668          phaseEl.style.opacity = "0";
10669          setTimeout(function () {
10670            phaseEl.textContent = phases[i];
10671            phaseEl.style.opacity = "0.85";
10672            var delay = durations[i] || 1800;
10673            i++;
10674            if (i < phases.length) { setTimeout(next, delay); }
10675          }, 200);
10676        }
10677        next();
10678      }
10679
10680      var form = document.getElementById("analyze-form");
10681      var loading = document.getElementById("loading");
10682      var submitButton = document.getElementById("submit-button");
10683      var pathInput = document.getElementById("path");
10684      var GIT_MODE = !!(pathInput && pathInput.readOnly);
10685      var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
10686      var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
10687      var outputDirInput = document.getElementById("output_dir");
10688      var reportTitleInput = document.getElementById("report_title");
10689      var previewPanel = document.getElementById("preview-panel");
10690      var refreshButton = document.getElementById("refresh-preview");
10691      var refreshPreviewInline = document.getElementById("refresh-preview-inline");
10692      var useSamplePath = document.getElementById("use-sample-path");
10693      var useDefaultOutput = document.getElementById("use-default-output");
10694      var browsePath = document.getElementById("browse-path");
10695      var browseOutputDir = document.getElementById("browse-output-dir");
10696      var browseCoverage = document.getElementById("browse-coverage");
10697      var coverageInput = document.getElementById("coverage_file");
10698      var covScanStatus = document.getElementById("cov-scan-status");
10699      var coverageSuggestTimer = null;
10700      var covAutoFilled = false;
10701      var themeToggle = document.getElementById("theme-toggle");
10702      var mixedLinePolicy = document.getElementById("mixed_line_policy");
10703      var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
10704      var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
10705      var scanPreset = document.getElementById("scan_preset");
10706      var artifactPreset = document.getElementById("artifact_preset");
10707      var includeGlobsInput = document.getElementById("include_globs");
10708      var excludeGlobsInput = document.getElementById("exclude_globs");
10709
10710      // Quick-exclude chips — append pattern to exclude_globs textarea.
10711      document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
10712        chip.addEventListener("click", function() {
10713          var pattern = chip.getAttribute("data-pattern") || "";
10714          if (!pattern || !excludeGlobsInput) return;
10715          var current = excludeGlobsInput.value.trim();
10716          // For the "skip all" chip, replace any existing dep patterns cleanly.
10717          var patterns = pattern.split("\n");
10718          var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
10719          var added = false;
10720          patterns.forEach(function(p) {
10721            p = p.trim();
10722            if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
10723          });
10724          if (added) {
10725            excludeGlobsInput.value = lines.join("\n");
10726            excludeGlobsInput.dispatchEvent(new Event("input"));
10727          }
10728          chip.classList.add("active");
10729        });
10730      });
10731
10732      var liveReportTitle = document.getElementById("live-report-title");
10733      var navProjectPill = document.getElementById("nav-project-pill");
10734      var navProjectTitle = document.getElementById("nav-project-title");
10735      var reportTitlePreview = null;
10736      var wizardProgressFill = document.getElementById("wizard-progress-fill");
10737      var wizardProgressValue = document.getElementById("wizard-progress-value");
10738      var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
10739      var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
10740      var artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
10741      var reportTitleTouched = false;
10742      var currentStep = 1;
10743      var previewTimer = null;
10744      var quickScanBtn = document.getElementById("quick-scan-btn");
10745
10746      function dismissAnalysisModal() {
10747        if (loading) loading.classList.remove("active");
10748        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10749          var el = document.getElementById(id);
10750          if (el) el.classList.add("hidden");
10751        });
10752        var cancelBtn = document.getElementById("lc-cancel-btn");
10753        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
10754        var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
10755        var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
10756        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10757        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10758        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10759        if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10760        if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10761      }
10762
10763      var lcDismissBtn = document.getElementById("lc-dismiss");
10764      if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
10765
10766      function startAsyncAnalysis(formData) {
10767        var gitRepo = (formData.get("git_repo") || "").toString();
10768        var gitRef  = (formData.get("git_ref")  || "").toString();
10769        var pathVal = (gitRepo || (formData.get("path") || "")).toString();
10770        var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
10771
10772        var pathEl = document.getElementById("lc-path");
10773        if (pathEl) pathEl.textContent = displayPath;
10774
10775        ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
10776          var el = document.getElementById(id);
10777          if (el) el.classList.add("hidden");
10778        });
10779        var cancelBtn = document.getElementById("lc-cancel-btn");
10780        if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
10781        var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
10782        var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
10783        var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
10784        var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
10785        var phase0   = document.getElementById("lc-phase");   if (phase0)   phase0.textContent   = "Starting";
10786
10787        if (loading) loading.classList.add("active");
10788
10789        var startTime = Date.now();
10790        var elapsedTimer = setInterval(function() {
10791          var s = Math.floor((Date.now() - startTime) / 1000);
10792          var el = document.getElementById("lc-elapsed");
10793          if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
10794        }, 1000);
10795
10796        var warnShown = false, pollRetries = 0, activeWaitId = null;
10797
10798        function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
10799
10800        function lcShowCancelled() {
10801          clearInterval(elapsedTimer);
10802          var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
10803          var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
10804          var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
10805          var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
10806          var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
10807          var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
10808          var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
10809          var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
10810          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10811          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10812        }
10813
10814        var lcCancelBtn = document.getElementById("lc-cancel-btn");
10815        if (lcCancelBtn) {
10816          lcCancelBtn.onclick = function() {
10817            if (!activeWaitId) { dismissAnalysisModal(); return; }
10818            lcCancelBtn.disabled = true;
10819            lcCancelBtn.textContent = "Cancelling…";
10820            fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
10821              .then(function() { lcShowCancelled(); })
10822              .catch(function() { lcShowCancelled(); });
10823          };
10824        }
10825
10826        function lcShowError(msg) {
10827          clearInterval(elapsedTimer);
10828          lcSetPhase("Failed");
10829          var msgEl = document.getElementById("lc-err-msg");
10830          if (msgEl) msgEl.textContent = msg || "Analysis failed.";
10831          var errEl = document.getElementById("lc-err");
10832          var actEl = document.getElementById("lc-actions");
10833          if (errEl) errEl.classList.remove("hidden");
10834          if (actEl) actEl.classList.remove("hidden");
10835          if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
10836          if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
10837        }
10838
10839        function lcPoll(waitId) {
10840          fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
10841            .then(function(r) {
10842              if (!r.ok) throw new Error("HTTP " + r.status);
10843              return r.json();
10844            })
10845            .then(function(data) {
10846              pollRetries = 0;
10847              if (data.state === "complete") {
10848                clearInterval(elapsedTimer);
10849                lcSetPhase("Done");
10850                window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
10851              } else if (data.state === "failed") {
10852                lcShowError(data.message);
10853              } else if (data.state === "cancelled") {
10854                lcShowCancelled();
10855              } else {
10856                var s = Math.floor((Date.now() - startTime) / 1000);
10857                if (s > 90 && !warnShown) {
10858                  warnShown = true;
10859                  var w = document.getElementById("lc-warn");
10860                  if (w) w.classList.remove("hidden");
10861                }
10862                lcSetPhase(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
10863                setTimeout(function() { lcPoll(waitId); }, 1500);
10864              }
10865            })
10866            .catch(function() {
10867              pollRetries++;
10868              if (pollRetries >= 5) {
10869                lcShowError("Lost connection to server. Reload to check status.");
10870              } else {
10871                setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
10872              }
10873            });
10874        }
10875
10876        var params = new URLSearchParams(formData);
10877        fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
10878          .then(function(r) {
10879            var waitId = r.headers.get("x-wait-id");
10880            if (!waitId) { window.location.href = "/scan"; return; }
10881            activeWaitId = waitId;
10882            setTimeout(function() { lcPoll(waitId); }, 1500);
10883          })
10884          .catch(function(err) {
10885            lcShowError("Could not reach server: " + (err.message || err));
10886          });
10887      }
10888
10889      if (quickScanBtn) {
10890        quickScanBtn.addEventListener("click", function () {
10891          var pathVal = pathInput ? pathInput.value.trim() : "";
10892          if (!pathVal) {
10893            alert("Please enter or browse to a project path first.");
10894            return;
10895          }
10896          quickScanBtn.disabled = true;
10897          quickScanBtn.textContent = "Scanning...";
10898          if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
10899          startAsyncAnalysis(new FormData(form));
10900        });
10901      }
10902
10903      var mixedPolicyInfo = {
10904        code_only: {
10905          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.",
10906          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'
10907        },
10908        code_and_comment: {
10909          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.",
10910          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'
10911        },
10912        comment_only: {
10913          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.",
10914          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'
10915        },
10916        separate_mixed_category: {
10917          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.",
10918          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'
10919        }
10920      };
10921
10922      var scanPresetInfo = {
10923        balanced: {
10924          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.",
10925          chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
10926          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
10927          note: "Best when you want a stable local overview before making deeper adjustments.",
10928          apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10929        },
10930        code_focused: {
10931          description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
10932          chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
10933          example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
10934          note: "Use this when you mainly care about implementation size and want cleaner code totals.",
10935          apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10936        },
10937        comment_audit: {
10938          description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
10939          chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
10940          example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
10941          note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
10942          apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
10943        },
10944        deep_review: {
10945          description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
10946          chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
10947          example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
10948          note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
10949          apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
10950        }
10951      };
10952
10953      var artifactPresetInfo = {
10954        review: {
10955          description: "Review bundle enables HTML and PDF so you can inspect the result in-browser and still save a portable snapshot for sharing or archiving.",
10956          chips: ["HTML", "PDF"],
10957          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
10958        },
10959        full: {
10960          description: "Full bundle enables HTML, PDF, and JSON. It is the best choice when you want both human-readable outputs and a machine-friendly artifact for later processing.",
10961          chips: ["HTML", "PDF", "JSON"],
10962          example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
10963        },
10964        html_only: {
10965          description: "HTML only keeps the run lightweight and browser-first. It is ideal for quick local inspection when you do not need a fixed snapshot or automation output.",
10966          chips: ["HTML only", "Fast local review"],
10967          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
10968        },
10969        machine: {
10970          description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
10971          chips: ["HTML", "JSON"],
10972          example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
10973        }
10974      };
10975
10976      function applyTheme(theme) {
10977        if (theme === "dark") document.body.classList.add("dark-theme");
10978        else document.body.classList.remove("dark-theme");
10979      }
10980
10981      function loadSavedTheme() {
10982        var saved = null;
10983        try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
10984        applyTheme(saved === "dark" ? "dark" : "light");
10985      }
10986
10987      function updateScrollProgress() {
10988        // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
10989        // Within each step, scroll position nudges the bar forward (max just below the next milestone).
10990        var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
10991        var stepEnd  = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
10992        var step = Math.min(Math.max(currentStep, 1), 4);
10993        var base = stepBase[step];
10994        var end  = stepEnd[step];
10995
10996        var scrollFrac = 0;
10997        var activePanel = document.querySelector(".wizard-step.active");
10998        if (activePanel) {
10999          var scrollTop = window.scrollY || window.pageYOffset || 0;
11000          var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
11001          var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
11002          var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
11003          var scrolled = scrollTop + viewH - panelTop;
11004          scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
11005        }
11006
11007        var percent = Math.round(base + (end - base) * scrollFrac);
11008        percent = Math.min(end, Math.max(base, percent));
11009        if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
11010        if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
11011      }
11012
11013      function updateWizardProgress() {
11014        updateScrollProgress();
11015      }
11016
11017      var stepDescriptions = [
11018        "Choose a project folder, apply scope filters, and preview which files will be counted.",
11019        "Configure how mixed code-plus-comment lines and docstrings are classified.",
11020        "Pick your output formats, scan preset, and where reports are saved.",
11021        "Review all settings and launch the analysis."
11022      ];
11023
11024      function updateStepNav(step) {
11025        var infoLabel = document.getElementById("step-nav-info-label");
11026        var infoDesc  = document.getElementById("step-nav-info-desc");
11027        if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
11028        if (infoDesc)  infoDesc.textContent  = stepDescriptions[step - 1] || "";
11029      }
11030
11031      function updateSidebarSummary() {
11032        var sumPath    = document.getElementById("sum-path");
11033        var sumPreset  = document.getElementById("sum-preset");
11034        var sumOutput  = document.getElementById("sum-output");
11035        var sidebarSummary = document.getElementById("sidebar-summary");
11036        var pathVal    = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
11037        var presetVal  = (scanPreset && scanPreset.value)    ? scanPreset.value.replace(/_/g, " ")    : "";
11038        var outputVal  = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
11039        if (sumPath)   sumPath.textContent   = pathVal   || "—";
11040        if (sumPreset) sumPreset.textContent = presetVal || "—";
11041        if (sumOutput) sumOutput.textContent = outputVal || "—";
11042        if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
11043      }
11044
11045      function setStep(step, pushHistory) {
11046        currentStep = step;
11047        stepPanels.forEach(function (panel) {
11048          panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
11049        });
11050        stepButtons.forEach(function (button) {
11051          button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
11052        });
11053        var layoutEl = document.querySelector(".layout");
11054        if (layoutEl) layoutEl.setAttribute("data-active-step", step);
11055        updateWizardProgress();
11056        updateStepNav(step);
11057        stepButtons.forEach(function(btn) {
11058          var t = Number(btn.getAttribute("data-step-target"));
11059          btn.classList.toggle("done", t < step);
11060        });
11061        updateSidebarSummary();
11062
11063        if (pushHistory !== false) {
11064          try {
11065            history.pushState({ wizardStep: step }, "", "#step" + step);
11066          } catch (e) {}
11067        }
11068
11069        window.scrollTo({ top: 0, behavior: "instant" });
11070      }
11071
11072      window.addEventListener("popstate", function (e) {
11073        if (e.state && e.state.wizardStep) {
11074          setStep(e.state.wizardStep, false);
11075        } else {
11076          var hashMatch = location.hash.match(/^#step([1-4])$/);
11077          if (hashMatch) setStep(Number(hashMatch[1]), false);
11078        }
11079      });
11080
11081      function inferTitleFromPath(value) {
11082        if (!value) return "project";
11083        var cleaned = value.replace(/[\/\\]+$/, "");
11084        var parts = cleaned.split(/[\/\\]/).filter(Boolean);
11085        return parts.length ? parts[parts.length - 1] : value;
11086      }
11087
11088      function updateReportTitleFromPath() {
11089        var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
11090        if (!reportTitleTouched) {
11091          reportTitleInput.value = inferred;
11092        }
11093        var title = reportTitleInput.value || inferred;
11094        if (liveReportTitle) liveReportTitle.textContent = title;
11095        if (reportTitlePreview) reportTitlePreview.textContent = title;
11096        document.title = "OxideSLOC | " + title;
11097
11098        var projectPath = (pathInput.value || "").trim();
11099        if (navProjectPill && navProjectTitle) {
11100          if (projectPath.length > 0) {
11101            navProjectTitle.textContent = inferred;
11102            navProjectPill.classList.add("visible");
11103          } else {
11104            navProjectTitle.textContent = "";
11105            navProjectPill.classList.remove("visible");
11106          }
11107        }
11108      }
11109
11110      function updateMixedPolicyUI() {
11111        var key = mixedLinePolicy.value || "code_only";
11112        var info = mixedPolicyInfo[key];
11113        document.getElementById("mixed-policy-description").textContent = info.description;
11114        document.getElementById("mixed-policy-example").textContent = info.example;
11115      }
11116
11117      function updatePythonDocstringUI() {
11118        var checked = !!pythonDocstrings.checked;
11119        document.getElementById("python-docstring-example").textContent = checked
11120          ? 'def greet():\n    """Greet the user."""  ← comment\n    print("hi")'
11121          : 'def greet():\n    """Greet the user."""  ← not counted\n    print("hi")';
11122        document.getElementById("python-docstring-live-help").textContent = checked
11123          ? "Enabled: docstrings contribute to comment-style totals."
11124          : "Disabled: docstrings are not counted as comment content.";
11125      }
11126
11127      function renderPresetChips(targetId, chips) {
11128        var target = document.getElementById(targetId);
11129        if (!target) return;
11130        target.innerHTML = (chips || []).map(function (chip) {
11131          return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
11132        }).join('');
11133      }
11134
11135      function updatePresetDescriptions() {
11136        var scanInfo = scanPresetInfo[scanPreset.value];
11137        var artifactInfo = artifactPresetInfo[artifactPreset.value];
11138        document.getElementById("scan-preset-description").textContent = scanInfo.description;
11139        document.getElementById("scan-preset-example").textContent = scanInfo.example;
11140        document.getElementById("scan-preset-note").textContent = scanInfo.note;
11141        document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
11142        document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
11143        renderPresetChips("scan-preset-summary", scanInfo.chips);
11144        renderPresetChips("artifact-preset-summary", artifactInfo.chips);
11145      }
11146
11147      function applyScanPreset() {
11148        var info = scanPresetInfo[scanPreset.value];
11149        if (!info || !info.apply) return;
11150        mixedLinePolicy.value = info.apply.mixed;
11151        pythonDocstrings.checked = !!info.apply.docstrings;
11152        document.getElementById("generated_file_detection").value = info.apply.generated;
11153        document.getElementById("minified_file_detection").value = info.apply.minified;
11154        document.getElementById("vendor_directory_detection").value = info.apply.vendor;
11155        document.getElementById("include_lockfiles").value = info.apply.lockfiles;
11156        document.getElementById("binary_file_behavior").value = info.apply.binary;
11157        updateMixedPolicyUI();
11158        updatePythonDocstringUI();
11159      }
11160
11161      function applyArtifactPreset() {
11162        var enabled = { html: false, pdf: false };
11163        if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
11164        if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
11165        if (artifactPreset.value === "html_only") { enabled.html = true; }
11166        if (artifactPreset.value === "machine") { enabled.html = true; }
11167
11168        artifactCards.forEach(function (card) {
11169          var artifact = card.getAttribute("data-artifact");
11170          if (artifact === "json") return;
11171          var checked = !!enabled[artifact];
11172          var checkbox = card.querySelector(".artifact-checkbox");
11173          checkbox.checked = checked;
11174          card.classList.toggle("selected", checked);
11175        });
11176      }
11177
11178      function toggleArtifactCard(card) {
11179        var checkbox = card.querySelector(".artifact-checkbox");
11180        checkbox.checked = !checkbox.checked;
11181        card.classList.toggle("selected", checkbox.checked);
11182      }
11183
11184      function updateReview() {
11185        var scanSummary = document.getElementById("review-scan-summary");
11186        var countSummary = document.getElementById("review-count-summary");
11187        var artifactSummary = document.getElementById("review-artifact-summary");
11188        var outputSummary = document.getElementById("review-output-summary");
11189        var previewSummary = document.getElementById("review-preview-summary");
11190        var readinessSummary = document.getElementById("review-readiness-summary");
11191        var includeText = document.getElementById("include_globs").value.trim();
11192        var excludeText = document.getElementById("exclude_globs").value.trim();
11193        var sidePathPreview = document.getElementById("side-path-preview");
11194        var sideOutputPreview = document.getElementById("side-output-preview");
11195        var sideTitlePreview = document.getElementById("side-title-preview");
11196
11197        if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
11198        if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
11199        if (sideTitlePreview) {
11200          var rt = document.getElementById("report_title");
11201          sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
11202        }
11203
11204        scanSummary.innerHTML = ""
11205          + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
11206          + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
11207          + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
11208
11209        countSummary.innerHTML = ""
11210          + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
11211          + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
11212          + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
11213          + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
11214          + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
11215          + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
11216          + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
11217          + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
11218
11219        var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
11220        artifactSummary.innerHTML = ""
11221          + "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
11222          + "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</li>";
11223
11224        outputSummary.innerHTML = ""
11225          + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
11226          + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
11227
11228        if (previewSummary) {
11229          if (GIT_MODE) {
11230            previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode — the repository will be cloned and fully analyzed during the scan run.</li>';
11231          } else {
11232          var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
11233          var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
11234          var statMap = {};
11235          statButtons.forEach(function (button) {
11236            var valueNode = button.querySelector('.scope-stat-value');
11237            statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
11238          });
11239          previewSummary.innerHTML = ''
11240            + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
11241            + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
11242            + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
11243            + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
11244            + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
11245            + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
11246
11247          if (readinessSummary) {
11248            var selectedArtifactsCount = selectedArtifacts.length;
11249            readinessSummary.innerHTML = ''
11250              + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
11251              + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
11252              + '<li>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
11253              + '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? 'yes' : 'no') + '</li>';
11254          }
11255          } // end else (non-GIT_MODE)
11256        }
11257      }
11258
11259      function escapeHtml(value) {
11260        return String(value)
11261          .replace(/&/g, "&amp;")
11262          .replace(/</g, "&lt;")
11263          .replace(/>/g, "&gt;")
11264          .replace(/"/g, "&quot;")
11265          .replace(/'/g, "&#39;");
11266      }
11267
11268      function isPythonVisible() {
11269        return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
11270      }
11271
11272      function syncPythonVisibility() {
11273        var html = previewPanel.textContent || "";
11274        var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
11275        pythonWraps.forEach(function (node) {
11276          node.classList.toggle("hidden", !hasPython);
11277        });
11278      }
11279
11280      function attachPreviewInteractions() {
11281        var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
11282        var treeContainer = previewPanel.querySelector(".file-explorer-tree");
11283        var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
11284        var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
11285        var filterSelect = previewPanel.querySelector("#explorer-filter-select");
11286        var searchInput = previewPanel.querySelector("#explorer-search");
11287        var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
11288        var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
11289        var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
11290        var activeFilter = "all";
11291        var activeLanguage = "";
11292        var searchTerm = "";
11293        var currentSortKey = null;
11294        var currentSortOrder = "asc";
11295        var childRows = {};
11296
11297        rows.forEach(function (row) {
11298          var parentId = row.getAttribute("data-parent-id") || "";
11299          var rowId = row.getAttribute("data-row-id") || "";
11300          if (!childRows[parentId]) childRows[parentId] = [];
11301          childRows[parentId].push(rowId);
11302        });
11303
11304        function rowById(id) {
11305          return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
11306        }
11307
11308        function hasCollapsedAncestor(row) {
11309          var parentId = row.getAttribute("data-parent-id");
11310          while (parentId) {
11311            var parent = rowById(parentId);
11312            if (!parent) break;
11313            if (parent.getAttribute("data-expanded") === "false") return true;
11314            parentId = parent.getAttribute("data-parent-id");
11315          }
11316          return false;
11317        }
11318
11319        function updateToggleGlyph(row) {
11320          var toggle = row.querySelector(".tree-toggle");
11321          if (!toggle) return;
11322          toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
11323        }
11324
11325        function rowSortValue(row, key) {
11326          return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
11327        }
11328
11329        function updateSortButtons() {
11330          sortButtons.forEach(function (button) {
11331            var isActive = button.getAttribute("data-sort-key") === currentSortKey;
11332            var indicator = button.querySelector(".tree-sort-indicator");
11333            button.classList.toggle("active", isActive);
11334            button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
11335            if (indicator) {
11336              indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
11337            }
11338          });
11339        }
11340
11341        function sortSiblingRows() {
11342          if (!treeContainer) {
11343            updateSortButtons();
11344            return;
11345          }
11346
11347          var rowMap = {};
11348          var childrenMap = {};
11349          rows.forEach(function (row) {
11350            var rowId = row.getAttribute("data-row-id");
11351            var parentId = row.getAttribute("data-parent-id") || "";
11352            rowMap[rowId] = row;
11353            if (!childrenMap[parentId]) childrenMap[parentId] = [];
11354            childrenMap[parentId].push(rowId);
11355          });
11356
11357          Object.keys(childrenMap).forEach(function (parentId) {
11358            if (!parentId) return;
11359            childrenMap[parentId].sort(function (a, b) {
11360              var rowA = rowMap[a];
11361              var rowB = rowMap[b];
11362              if (!currentSortKey) {
11363                return Number(a) - Number(b);
11364              }
11365              var valueA = rowSortValue(rowA, currentSortKey);
11366              var valueB = rowSortValue(rowB, currentSortKey);
11367              if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
11368              if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
11369              var fallbackA = rowSortValue(rowA, "name");
11370              var fallbackB = rowSortValue(rowB, "name");
11371              if (fallbackA < fallbackB) return -1;
11372              if (fallbackA > fallbackB) return 1;
11373              return Number(a) - Number(b);
11374            });
11375          });
11376
11377          var orderedIds = [];
11378          function pushChildren(parentId) {
11379            (childrenMap[parentId] || []).forEach(function (childId) {
11380              orderedIds.push(childId);
11381              pushChildren(childId);
11382            });
11383          }
11384
11385          (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
11386            orderedIds.push(topId);
11387            pushChildren(topId);
11388          });
11389
11390          orderedIds.forEach(function (id) {
11391            if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
11392          });
11393          updateSortButtons();
11394        }
11395
11396        function updateLanguageButtons() {
11397          languageButtons.forEach(function (button) {
11398            var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
11399            var isActive = languageValue === activeLanguage;
11400            button.classList.toggle("active", isActive);
11401          });
11402        }
11403
11404        function rowSelfMatches(row) {
11405          var kind = row.getAttribute("data-kind");
11406          var status = row.getAttribute("data-status");
11407          var language = (row.getAttribute("data-language") || "").toLowerCase();
11408          var name = row.getAttribute("data-name-lower") || "";
11409          var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
11410          var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
11411          var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
11412          var passesLanguage = !activeLanguage || language === activeLanguage;
11413          return passesFilter && passesSearch && passesLanguage;
11414        }
11415
11416        function hasMatchingDescendant(rowId) {
11417          return (childRows[rowId] || []).some(function (childId) {
11418            var childRow = rowById(childId);
11419            return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
11420          });
11421        }
11422
11423        function rowMatches(row) {
11424          if (rowSelfMatches(row)) return true;
11425          return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
11426        }
11427
11428        function resetViewState() {
11429          activeFilter = "all";
11430          activeLanguage = "";
11431          searchTerm = "";
11432          currentSortKey = null;
11433          currentSortOrder = "asc";
11434          dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11435          if (searchInput) searchInput.value = "";
11436          if (filterSelect) filterSelect.value = "all";
11437          updateLanguageButtons();
11438        }
11439
11440        function applyVisibility() {
11441          rows.forEach(function (row) {
11442            var visible = rowMatches(row) && !hasCollapsedAncestor(row);
11443            row.classList.toggle("hidden-by-filter", !visible);
11444            row.style.display = visible ? "grid" : "none";
11445          });
11446          buttons.forEach(function (button) {
11447            button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
11448          });
11449          if (filterSelect) filterSelect.value = activeFilter;
11450        }
11451
11452        var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
11453        var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
11454        var originalStats = {};
11455        buttons.forEach(function (btn) {
11456          var f = btn.getAttribute('data-filter');
11457          var v = btn.querySelector('.scope-stat-value');
11458          if (f && v) originalStats[f] = v.textContent;
11459        });
11460
11461        function applySubmoduleStats(statsJson) {
11462          try {
11463            var s = JSON.parse(statsJson);
11464            buttons.forEach(function (btn) {
11465              var f = btn.getAttribute('data-filter');
11466              var v = btn.querySelector('.scope-stat-value');
11467              if (!v) return;
11468              if (f === 'dir') v.textContent = s.dirs;
11469              else if (f === 'file') v.textContent = s.files;
11470              else if (f === 'supported') v.textContent = s.supported;
11471              else if (f === 'skipped') v.textContent = s.skipped;
11472              else if (f === 'unsupported') v.textContent = s.unsupported;
11473            });
11474          } catch (e) {}
11475        }
11476
11477        function restoreBaseRepoStats() {
11478          buttons.forEach(function (btn) {
11479            var f = btn.getAttribute('data-filter');
11480            var v = btn.querySelector('.scope-stat-value');
11481            if (v && originalStats[f]) v.textContent = originalStats[f];
11482          });
11483          submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11484          if (baseRepoBtn) baseRepoBtn.style.display = 'none';
11485        }
11486
11487        submoduleChips.forEach(function (chip) {
11488          chip.addEventListener('click', function () {
11489            var statsJson = chip.getAttribute('data-sub-stats');
11490            if (!statsJson) return;
11491            submoduleChips.forEach(function (c) { c.classList.remove('active'); });
11492            chip.classList.add('active');
11493            applySubmoduleStats(statsJson);
11494            if (baseRepoBtn) baseRepoBtn.style.display = '';
11495          });
11496        });
11497
11498        if (baseRepoBtn) {
11499          baseRepoBtn.addEventListener('click', function () {
11500            restoreBaseRepoStats();
11501            resetViewState();
11502            sortSiblingRows();
11503            applyVisibility();
11504          });
11505        }
11506
11507        buttons.forEach(function (button) {
11508          button.addEventListener("click", function () {
11509            var filterValue = button.getAttribute("data-filter") || "all";
11510            if (filterValue === "reset-view") {
11511              restoreBaseRepoStats();
11512              resetViewState();
11513              sortSiblingRows();
11514              applyVisibility();
11515              return;
11516            }
11517            activeFilter = filterValue;
11518            applyVisibility();
11519          });
11520        });
11521
11522        rows.forEach(function (row) {
11523          updateToggleGlyph(row);
11524          var toggle = row.querySelector(".tree-toggle");
11525          if (toggle) {
11526            toggle.addEventListener("click", function () {
11527              var expanded = row.getAttribute("data-expanded") !== "false";
11528              row.setAttribute("data-expanded", expanded ? "false" : "true");
11529              updateToggleGlyph(row);
11530              applyVisibility();
11531            });
11532          }
11533        });
11534
11535        actionButtons.forEach(function (button) {
11536          button.addEventListener("click", function () {
11537            var action = button.getAttribute("data-explorer-action");
11538            if (action === "expand-all") {
11539              dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
11540            } else if (action === "collapse-all") {
11541              dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
11542            } else if (action === "clear-filters") {
11543              resetViewState();
11544            }
11545            sortSiblingRows();
11546            applyVisibility();
11547          });
11548        });
11549
11550        if (filterSelect) {
11551          filterSelect.addEventListener("change", function () {
11552            activeFilter = filterSelect.value || "all";
11553            applyVisibility();
11554          });
11555        }
11556
11557        languageButtons.forEach(function (button) {
11558          button.addEventListener("click", function () {
11559            activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
11560            updateLanguageButtons();
11561            applyVisibility();
11562          });
11563        });
11564
11565        sortButtons.forEach(function (button) {
11566          button.addEventListener("click", function () {
11567            var sortKey = button.getAttribute("data-sort-key");
11568            if (currentSortKey === sortKey) {
11569              currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
11570            } else {
11571              currentSortKey = sortKey;
11572              currentSortOrder = "asc";
11573            }
11574            sortSiblingRows();
11575            applyVisibility();
11576          });
11577        });
11578
11579        if (searchInput) {
11580          searchInput.addEventListener("input", function () {
11581            searchTerm = searchInput.value.trim().toLowerCase();
11582            applyVisibility();
11583          });
11584        }
11585
11586        updateLanguageButtons();
11587        sortSiblingRows();
11588        applyVisibility();
11589      }
11590
11591      function loadPreview() {
11592        if (!previewPanel || !pathInput) return;
11593        if (GIT_MODE) {
11594          previewPanel.innerHTML = '<div class="preview-error" style="color:var(--muted);font-style:italic;">Preview is not available for remote git refs. The scan will check out the source at runtime.</div>';
11595          return;
11596        }
11597        var path = pathInput.value.trim();
11598        var zeroWarn = document.getElementById('zero-files-warning');
11599        if (!path) {
11600          previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
11601          if (zeroWarn) zeroWarn.style.display = 'none';
11602          return;
11603        }
11604        var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
11605        var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
11606        previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
11607        var previewUrl = "/preview?path=" + encodeURIComponent(path)
11608          + "&include_globs=" + encodeURIComponent(includeValue)
11609          + "&exclude_globs=" + encodeURIComponent(excludeValue);
11610        fetch(previewUrl)
11611          .then(function (response) { return response.text(); })
11612          .then(function (html) {
11613            previewPanel.innerHTML = html;
11614            attachPreviewInteractions();
11615            syncPythonVisibility();
11616            updateReview();
11617            setTimeout(collapseLanguagePills, 50);
11618            var explorerWrap = previewPanel.querySelector('.explorer-wrap');
11619            var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
11620            var sizeText = document.getElementById('project-size-text');
11621            var sizeBtn = document.getElementById('project-size-btn');
11622            if (sizeText && projectSize) {
11623              sizeText.textContent = 'Project size: ' + projectSize;
11624              if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
11625            } else if (sizeText) {
11626              sizeText.textContent = 'Project size: —';
11627            }
11628            if (zeroWarn) {
11629              var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
11630              var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
11631              var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
11632              var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
11633              if (supportedCount === 0 && fileCount > 0) {
11634                zeroWarn.textContent = '⚠ Warning: No supported source files detected—this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
11635                zeroWarn.style.display = '';
11636              } else {
11637                zeroWarn.style.display = 'none';
11638              }
11639            }
11640          })
11641          .catch(function (err) {
11642            previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
11643          });
11644      }
11645
11646      function pickDirectory(targetInput, kind) {
11647        var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
11648        if (browseButton) browseButton.disabled = true;
11649
11650        if (previewPanel && targetInput === pathInput) {
11651          previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
11652        }
11653
11654        fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "&current=" + encodeURIComponent(targetInput.value || ""))
11655          .then(function (response) { return response.json(); })
11656          .then(function (data) {
11657            if (data && data.selected_path) {
11658              targetInput.value = data.selected_path;
11659
11660              if (targetInput === pathInput) {
11661                updateReportTitleFromPath();
11662                autoSetOutputDir(data.selected_path);
11663                fetchProjectHistory(data.selected_path);
11664                loadPreview();
11665                suggestCoverageFile(data.selected_path);
11666              }
11667
11668              updateReview();
11669            } else if (targetInput === pathInput) {
11670              // Cancelled — keep existing value and refresh preview with current path
11671              loadPreview();
11672            }
11673          })
11674          .catch(function () {
11675            window.alert("Directory picker request failed.");
11676            if (previewPanel && targetInput === pathInput) {
11677              previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
11678            }
11679          })
11680          .finally(function () {
11681            if (browseButton) browseButton.disabled = false;
11682          });
11683      }
11684
11685      if (themeToggle) {
11686        themeToggle.addEventListener("click", function () {
11687          var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
11688          applyTheme(nextTheme);
11689          try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
11690        });
11691      }
11692
11693      stepButtons.forEach(function (button) {
11694        button.addEventListener("click", function () {
11695          setStep(Number(button.getAttribute("data-step-target")));
11696        });
11697      });
11698
11699      Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
11700        button.addEventListener("click", function () {
11701          setStep(Number(button.getAttribute("data-step-target")) || 1);
11702        });
11703      });
11704
11705      Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
11706        button.addEventListener("click", function () {
11707          updateReview();
11708          setStep(Number(button.getAttribute("data-next")));
11709        });
11710      });
11711
11712      Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
11713        button.addEventListener("click", function () {
11714          setStep(Number(button.getAttribute("data-prev")));
11715        });
11716      });
11717
11718      document.addEventListener("keydown", function (e) {
11719        var tag = (document.activeElement || {}).tagName || "";
11720        if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
11721        if (e.altKey || e.ctrlKey || e.metaKey) return;
11722        if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
11723        else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
11724      });
11725
11726      if (useSamplePath) {
11727        useSamplePath.addEventListener("click", function () {
11728          pathInput.value = "tests/fixtures/basic";
11729          updateReportTitleFromPath();
11730          autoSetOutputDir("tests/fixtures/basic");
11731          loadPreview();
11732          suggestCoverageFile("tests/fixtures/basic");
11733        });
11734      }
11735
11736      if (useDefaultOutput) {
11737        useDefaultOutput.addEventListener("click", function () {
11738          delete outputDirInput.dataset.userEdited;
11739          autoSetOutputDir(pathInput ? pathInput.value : "");
11740          updateReview();
11741        });
11742      }
11743
11744      if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
11745      if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
11746      if (browseCoverage) {
11747        browseCoverage.addEventListener("click", function () {
11748          browseCoverage.disabled = true;
11749          var currentVal = coverageInput ? coverageInput.value : "";
11750          fetch("/pick-directory?kind=coverage&current=" + encodeURIComponent(currentVal))
11751            .then(function (r) { return r.json(); })
11752            .then(function (d) {
11753              if (d && d.selected_path && coverageInput) {
11754                coverageInput.value = d.selected_path;
11755                setCovStatus("idle");
11756              }
11757            })
11758            .catch(function () {})
11759            .finally(function () { browseCoverage.disabled = false; });
11760        });
11761      }
11762
11763      function setCovStatus(state, opts) {
11764        if (!covScanStatus) return;
11765        opts = opts || {};
11766        covScanStatus.className = "cov-scan-status cov-scan-" + state;
11767        if (state === "idle") { covScanStatus.innerHTML = ""; return; }
11768        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>';
11769        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>';
11770        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>';
11771        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>';
11772        var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
11773        var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
11774        if (state === "scanning") {
11775          html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
11776        } else if (state === "found") {
11777          var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11778          html += '<div class="cov-scan-title">Using this file' + tb + '</div>';
11779          html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
11780          html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove this file</button></div>';
11781        } else if (state === "hint") {
11782          var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
11783          html += '<div class="cov-scan-title">' + tb2 + ' detected &mdash; no coverage file found yet</div>';
11784          html += '<div class="cov-scan-sub">Generate one with:</div>';
11785          html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></div>';
11786        } else if (state === "none") {
11787          html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
11788          html += '<div class="cov-scan-sub">Supported: LCOV .info &middot; Cobertura XML &middot; JaCoCo XML</div>';
11789        }
11790        html += '</div></div>';
11791        covScanStatus.innerHTML = html;
11792        if (state === "found") {
11793          var useBtn = covScanStatus.querySelector(".cov-scan-use");
11794          if (useBtn) useBtn.addEventListener("click", function () {
11795            if (coverageInput) coverageInput.value = "";
11796            covAutoFilled = false;
11797            setCovStatus("idle");
11798          });
11799        }
11800      }
11801
11802      function suggestCoverageFile(projectPath) {
11803        if (!coverageInput || !covScanStatus) return;
11804        if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11805        if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
11806        clearTimeout(coverageSuggestTimer);
11807        if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
11808        setCovStatus("scanning");
11809        coverageSuggestTimer = setTimeout(function () {
11810          fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
11811            .then(function (r) { return r.json(); })
11812            .then(function (d) {
11813              if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
11814              if (!d) { setCovStatus("none"); return; }
11815              if (d.found) {
11816                if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
11817                setCovStatus("found", { found: d.found, tool: d.tool });
11818              } else if (d.tool && d.hint) {
11819                setCovStatus("hint", { tool: d.tool, hint: d.hint });
11820              } else {
11821                setCovStatus("none");
11822              }
11823            })
11824            .catch(function () { setCovStatus("idle"); });
11825        }, 600);
11826      }
11827
11828      if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
11829
11830      if (coverageInput) coverageInput.addEventListener("input", function () {
11831        covAutoFilled = false;
11832        if (!this.value.trim()) setCovStatus("idle");
11833      });
11834
11835      // ── Language pill overflow: collapse to "+N more" chip ─────────────
11836      function collapseLanguagePills() {
11837        var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
11838        rows.forEach(function(row) {
11839          // Remove any previous overflow chip
11840          var prev = row.querySelector('.lang-overflow-chip');
11841          if (prev) prev.remove();
11842          var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
11843          pills.forEach(function(p) { p.style.display = ''; });
11844          if (!pills.length) return;
11845
11846          // Measure after restoring all pills
11847          var containerRight = row.getBoundingClientRect().right;
11848          var hidden = [];
11849          for (var i = pills.length - 1; i >= 1; i--) {
11850            var rect = pills[i].getBoundingClientRect();
11851            if (rect.right > containerRight + 2) {
11852              hidden.unshift(pills[i]);
11853              pills[i].style.display = 'none';
11854            } else {
11855              break;
11856            }
11857          }
11858
11859          if (hidden.length) {
11860            var chip = document.createElement('button');
11861            chip.type = 'button';
11862            chip.className = 'language-pill lang-overflow-chip';
11863            var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
11864            chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
11865            row.appendChild(chip);
11866          }
11867        });
11868      }
11869
11870      // Run after preview loads (preview panel populates language pills)
11871      var _origLoadPreviewCb = window.__previewLoaded;
11872      document.addEventListener('previewLoaded', collapseLanguagePills);
11873      window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
11874      setTimeout(collapseLanguagePills, 400);
11875
11876      // ── Project history & output dir auto-set ──────────────────────────
11877      var wsOutputRoot   = document.getElementById("ws-output-root");
11878      var wsScanCount    = document.getElementById("ws-scan-count");
11879      var wsLastScan     = document.getElementById("ws-last-scan");
11880      var historyBadge   = document.getElementById("path-history-badge");
11881      var historyTimer   = null;
11882
11883      var wsOutputLink = document.getElementById("ws-output-link");
11884      function syncStripOutputRoot() {
11885        var val = outputDirInput ? outputDirInput.value : "";
11886        var display = val || "project/sloc";
11887        if (wsOutputRoot) wsOutputRoot.textContent = display;
11888        if (wsOutputLink) wsOutputLink.dataset.folder = val;
11889      }
11890
11891      function autoSetOutputDir(projectPath) {
11892        if (!outputDirInput || outputDirInput.dataset.userEdited) return;
11893        if (GIT_MODE && GIT_OUTPUT_DIR) {
11894          outputDirInput.value = GIT_OUTPUT_DIR;
11895          syncStripOutputRoot();
11896          updateReview();
11897          return;
11898        }
11899        if (!projectPath || !projectPath.trim()) return;
11900        var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
11901        outputDirInput.value = cleaned + "/sloc";
11902        syncStripOutputRoot();
11903        updateReview();
11904      }
11905
11906      var wsBranch = document.getElementById("ws-branch");
11907
11908      function fetchProjectHistory(projectPath) {
11909        if (!projectPath || !projectPath.trim()) {
11910          if (wsScanCount) wsScanCount.textContent = "—";
11911          if (wsLastScan)  wsLastScan.textContent  = "—";
11912          if (wsBranch)    wsBranch.textContent    = "—";
11913          if (historyBadge) historyBadge.style.display = "none";
11914          return;
11915        }
11916        fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
11917          .then(function (r) { return r.ok ? r.json() : null; })
11918          .then(function (data) {
11919            if (!data) return;
11920            var countStr = data.scan_count > 0
11921              ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
11922              : "never";
11923            var tsStr = data.last_scan_timestamp
11924              ? data.last_scan_timestamp.replace(" UTC","")
11925              : "—";
11926            if (wsScanCount) wsScanCount.textContent = countStr;
11927            if (wsLastScan)  wsLastScan.textContent  = tsStr;
11928            if (wsBranch)    wsBranch.textContent    = data.last_git_branch || "—";
11929            if (data.scan_count > 0) {
11930              if (historyBadge) {
11931                var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
11932                historyBadge.textContent = data.scan_count + " previous scan" +
11933                  (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
11934                  "Last: " + (data.last_scan_timestamp || "—") +
11935                  " — " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?Math.round(v/1e3)+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
11936                historyBadge.className = "path-history-badge found";
11937                historyBadge.style.display = "";
11938              }
11939            } else {
11940              if (historyBadge) historyBadge.style.display = "none";
11941            }
11942          })
11943          .catch(function () {});
11944      }
11945
11946      function onPathChange() {
11947        var val = pathInput ? pathInput.value : "";
11948        updateReportTitleFromPath();
11949        autoSetOutputDir(val);
11950        updateSidebarSummary();
11951        clearTimeout(historyTimer);
11952        historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
11953        if (previewTimer) clearTimeout(previewTimer);
11954        previewTimer = setTimeout(loadPreview, 280);
11955        suggestCoverageFile(val);
11956      }
11957
11958      if (pathInput) {
11959        pathInput.addEventListener("input", onPathChange);
11960      }
11961
11962      if (outputDirInput) {
11963        outputDirInput.addEventListener("input", function () {
11964          outputDirInput.dataset.userEdited = "1";
11965          syncStripOutputRoot();
11966          updateReview();
11967        });
11968      }
11969
11970      [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
11971        if (!node) return;
11972        node.addEventListener("input", function () {
11973          updateReview();
11974          if (previewTimer) clearTimeout(previewTimer);
11975          previewTimer = setTimeout(loadPreview, 280);
11976        });
11977      });
11978
11979      ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
11980        var node = document.getElementById(id);
11981        if (node) node.addEventListener("change", updateReview);
11982      });
11983
11984      if (reportTitleInput) {
11985        reportTitleInput.addEventListener("input", function () {
11986          reportTitleTouched = reportTitleInput.value.trim().length > 0;
11987          updateReportTitleFromPath();
11988          updateReview();
11989        });
11990      }
11991
11992      if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
11993      if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
11994      if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
11995      if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
11996
11997      artifactCards.forEach(function (card) {
11998        card.addEventListener("click", function () {
11999          if (card.classList.contains("artifact-locked")) return;
12000          toggleArtifactCard(card);
12001          updateReview();
12002        });
12003      });
12004
12005      if (coverageInput) {
12006        coverageInput.addEventListener("input", function () {
12007          if (coverageInput.value.trim()) setCovStatus("idle");
12008        });
12009      }
12010
12011      if (form && loading && submitButton) {
12012        form.addEventListener("submit", function (e) {
12013          e.preventDefault();
12014          submitButton.disabled = true;
12015          submitButton.textContent = "Scanning...";
12016          startAsyncAnalysis(new FormData(form));
12017        });
12018      }
12019
12020      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
12021        btn.addEventListener('click', function () {
12022          var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
12023          if (!folder) return;
12024          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12025        });
12026      });
12027
12028      // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
12029      if (wsOutputLink) {
12030        wsOutputLink.addEventListener('click', function () {
12031          var folder = wsOutputLink.dataset.folder || '';
12032          if (!folder) return;
12033          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
12034        });
12035      }
12036
12037      loadSavedTheme();
12038      updateMixedPolicyUI();
12039      updatePythonDocstringUI();
12040      applyScanPreset();
12041      updatePresetDescriptions();
12042      applyArtifactPreset();
12043      updateReview();
12044      updateScrollProgress(); // initialise bar to 0% (step 1)
12045      window.addEventListener("scroll", updateScrollProgress, { passive: true });
12046      onPathChange();         // seed output dir, history badge, and preview from initial path
12047      loadPreview();
12048      updateStepNav(1);
12049
12050      // Restore step from URL hash on initial load (e.g., back-forward cache)
12051      (function() {
12052        var hashMatch = location.hash.match(/^#step([1-4])$/);
12053        if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
12054      })();
12055
12056      (function randomizeWatermarks() {
12057        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
12058        if (!wms.length) return;
12059        var placed = [];
12060        function tooClose(top, left) {
12061          for (var i = 0; i < placed.length; i++) {
12062            var dt = Math.abs(placed[i][0] - top);
12063            var dl = Math.abs(placed[i][1] - left);
12064            if (dt < 16 && dl < 12) return true;
12065          }
12066          return false;
12067        }
12068        function pick(leftBand) {
12069          for (var attempt = 0; attempt < 50; attempt++) {
12070            var top = Math.random() * 88 + 2;
12071            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12072            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12073          }
12074          var top = Math.random() * 88 + 2;
12075          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12076          placed.push([top, left]);
12077          return [top, left];
12078        }
12079        var half = Math.floor(wms.length / 2);
12080        wms.forEach(function (img, i) {
12081          var pos = pick(i < half);
12082          var size = Math.floor(Math.random() * 80 + 110);
12083          var rot = (Math.random() * 360).toFixed(1);
12084          var op = (Math.random() * 0.08 + 0.13).toFixed(2);
12085          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;
12086        });
12087      })();
12088
12089      (function spawnCodeParticles() {
12090        var container = document.getElementById('code-particles');
12091        if (!container) return;
12092        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
12093        for (var i = 0; i < 38; i++) {
12094          (function(idx) {
12095            var el = document.createElement('span');
12096            el.className = 'code-particle';
12097            el.textContent = snippets[idx % snippets.length];
12098            var left = Math.random() * 94 + 2;
12099            var top = Math.random() * 88 + 6;
12100            var dur = (Math.random() * 10 + 9).toFixed(1);
12101            var delay = (Math.random() * 18).toFixed(1);
12102            var rot = (Math.random() * 26 - 13).toFixed(1);
12103            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12104            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';
12105            container.appendChild(el);
12106          })(i);
12107        }
12108      })();
12109    })();
12110  </script>
12111  <script nonce="{{ csp_nonce }}">
12112    (function () {
12113      var raw = {{ prefill_json|safe }};
12114      if (!raw || typeof raw !== 'object' || !raw.path) return;
12115      function setVal(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12116      function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
12117      function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
12118      setVal('path-input', raw.path || '');
12119      setVal('include-globs', raw.include_globs || '');
12120      setVal('exclude-globs', raw.exclude_globs || '');
12121      setVal('output-dir', raw.output_dir || '');
12122      setVal('report-title', raw.report_title || '');
12123      if (raw.submodule_breakdown) setChecked('submodule-breakdown', true);
12124      setSelect('mixed-line-policy', raw.mixed_line_policy || 'code_only');
12125      setChecked('python-docstrings-as-comments', !!raw.python_docstrings_as_comments);
12126      setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
12127      setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
12128      setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
12129      if (raw.include_lockfiles) setSelect('include-lockfiles', 'enabled');
12130      setSelect('binary-file-behavior', raw.binary_file_behavior || 'skip');
12131      setChecked('generate-html', raw.generate_html !== false);
12132      setChecked('generate-pdf', !!raw.generate_pdf);
12133      // Trigger dynamic UI updates after pre-fill.
12134      setTimeout(function () {
12135        var pathEl = document.getElementById('path-input');
12136        if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
12137        var policyEl = document.getElementById('mixed-line-policy');
12138        if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
12139      }, 80);
12140    })();
12141  </script>
12142  <script nonce="{{ csp_nonce }}">
12143  (function(){
12144    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'}];
12145    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);});}
12146    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12147    function init(){
12148      var btn=document.getElementById('settings-btn');if(!btn)return;
12149      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12150      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
12151      document.body.appendChild(m);
12152      var g=document.getElementById('scheme-grid');
12153      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);});
12154      var cl=document.getElementById('settings-close');
12155      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
12156      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');});
12157      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12158      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12159    }
12160    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12161  }());
12162  </script>
12163  <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
12164    <div class="wb-ftip-arrow"></div>
12165    <span id="wb-ftip-text"></span>
12166  </div>
12167  <script nonce="{{ csp_nonce }}">(function(){
12168    var tip=document.getElementById('wb-ftip');
12169    var txt=document.getElementById('wb-ftip-text');
12170    var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
12171    if(!tip||!txt)return;
12172    function pos(el){
12173      var r=el.getBoundingClientRect();
12174      tip.style.display='block';
12175      var tw=tip.offsetWidth;
12176      var lx=r.left+r.width/2-tw/2;
12177      if(lx<8)lx=8;
12178      if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
12179      tip.style.left=lx+'px';
12180      tip.style.top=(r.bottom+8)+'px';
12181      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';}
12182    }
12183    document.querySelectorAll('[data-wb-tip]').forEach(function(el){
12184      el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
12185      el.addEventListener('mouseleave',function(){tip.style.display='none';});
12186    });
12187  })();
12188  (function(){
12189    function fixArtifactHintSpacing(){
12190      var grid=document.querySelector('.artifact-grid');
12191      if(grid){grid.style.setProperty('margin-bottom','48px','important');}
12192    }
12193    if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
12194  }());
12195  </script>
12196  <footer class="site-footer">
12197    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
12198    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12199    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12200    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12201    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12202  </footer>
12203</body>
12204</html>
12205"##,
12206    ext = "html"
12207)]
12208struct IndexTemplate {
12209    version: &'static str,
12210    prefill_json: String,
12211    csp_nonce: String,
12212    git_repo: String,
12213    git_ref: String,
12214    git_label_json: String,
12215    git_output_dir_json: String,
12216}
12217
12218// ── SplashTemplate ────────────────────────────────────────────────────────────
12219
12220#[derive(Template)]
12221#[template(
12222    source = r##"
12223<!doctype html>
12224<html lang="en">
12225<head>
12226  <meta charset="utf-8">
12227  <meta name="viewport" content="width=device-width, initial-scale=1">
12228  <title>OxideSLOC — local code analysis - metrics, history and reports</title>
12229  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12230  <style nonce="{{ csp_nonce }}">
12231    :root {
12232      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
12233      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12234      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12235      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12236      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12237    }
12238    body.dark-theme {
12239      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12240      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12241    }
12242    *{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);}
12243    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12244    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
12245    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
12246    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
12247    @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));}}
12248    .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);}
12249    .top-nav-inner{max-width:1400px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12250    .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));}
12251    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
12252    .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;}
12253    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12254    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12255    @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; } }
12256    .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;}
12257    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12258    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12259    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
12260    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
12261    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
12262    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
12263    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
12264    .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);}
12265    .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;}
12266    .settings-close:hover{color:var(--text);background:var(--surface-2);}
12267    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
12268    .settings-modal-body{padding:14px 16px 16px;}
12269    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
12270    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
12271    .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;}
12272    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
12273    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
12274    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
12275    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
12276    .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;}
12277    .tz-select:focus{border-color:var(--oxide);}
12278    .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;}
12279    .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;}
12280    .page{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
12281    .hero{text-align:center;margin:0 auto 18px;}
12282    .hero-logo-wrap{display:inline-block;cursor:default;}
12283    .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;}
12284    .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;}
12285    .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
12286    .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;}
12287    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%);}
12288    .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;
12289      background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
12290      background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
12291      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;}
12292    @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
12293    @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
12294    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;}
12295    .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
12296    .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;}
12297    @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
12298    .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
12299    .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
12300    .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
12301    .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
12302    @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
12303    @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
12304    .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;}
12305    .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;}
12306    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
12307    .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12308    .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);}
12309    .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
12310    .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
12311    .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);}
12312    .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);}
12313    .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);}
12314    .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
12315    .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
12316    .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;}
12317    body.dark-theme .action-card-cta{color:var(--oxide);}
12318    .action-card.view .action-card-cta{color:var(--accent-2);}
12319    body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
12320    .action-card.compare .action-card-cta{color:#7c3aed;}
12321    body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
12322    .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);}
12323    .action-card.git-tools .action-card-cta{color:#15803d;}
12324    body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
12325    .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);}
12326    .action-card.trend .action-card-cta{color:#0e7490;}
12327    body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
12328    .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);}
12329    .action-card.automation .action-card-cta{color:#b45309;}
12330    body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
12331    .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);}
12332    .action-card.test-metrics .action-card-cta{color:#be185d;}
12333    body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
12334    .action-card:hover .action-card-cta{gap:12px;}
12335    .action-card.card-split{flex-direction:row;align-items:stretch;}
12336    .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
12337    .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
12338    .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
12339    .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
12340    .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
12341    .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
12342    .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;}
12343    .ac-badge.active{opacity:1;}
12344    .ac-badge.github{border-color:#555;color:#555;}
12345    .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
12346    .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
12347    .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
12348    .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
12349    body.dark-theme .ac-right-row{color:var(--muted);}
12350    body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
12351    @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
12352    .divider{height:1px;background:var(--line);margin:32px 0;}
12353    .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
12354    @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
12355    @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
12356    .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
12357      transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
12358    .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
12359    .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
12360    body.dark-theme .info-chip-val{color:var(--oxide);}
12361    .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
12362    .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
12363      background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
12364      white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
12365    .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
12366      border:6px solid transparent;border-top-color:var(--text);}
12367    .info-chip:hover .info-chip-tip{display:block;}
12368    .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
12369    .chip-slide.fading{filter:blur(5px);opacity:0;}
12370    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
12371    .site-footer a{color:var(--muted);}
12372    .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;}
12373    .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
12374    body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
12375    .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
12376    .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;}
12377    .lan-badge.local{background:var(--oxide-2);}
12378    .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
12379    .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);}
12380    body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
12381    .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;}
12382    .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
12383    .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
12384    .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;}
12385    body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
12386    .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;}
12387    .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);}
12388    body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
12389    body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
12390    .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
12391    .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;}
12392    @media (max-height: 1100px) {
12393      .page{padding-top:10px;}
12394      .hero{margin-bottom:10px;}
12395      .hero-logo{width:54px;height:60px;}
12396      .hero-logo-shadow{width:42px;}
12397      .hero-title{font-size:28px;}
12398      .hero-subtitle{font-size:13px;}
12399      .card-sections{gap:16px;margin-bottom:10px;}
12400      .card-section-grid-2,.card-section-grid-3{gap:10px;}
12401      .action-card{padding:8px 15px 8px;}
12402      .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
12403      .action-card-icon svg{width:18px;height:18px;}
12404      .action-card-title{font-size:13px;}
12405      .action-card-desc{font-size:11px;margin-bottom:6px;}
12406      .action-card-cta{font-size:11px;}
12407      .ac-right-row{font-size:11px;}
12408      .divider{margin:14px 0;}
12409      .info-strip{gap:7px;margin-bottom:12px;}
12410      .info-chip{padding:7px 10px;}
12411      .info-chip-val{font-size:13px;}
12412      .info-chip-label{font-size:9px;}
12413      .site-footer{padding:8px 24px;font-size:12px;}
12414    }
12415    @media (max-height: 850px) {
12416      .page{padding-top:6px;}
12417      .hero{margin-bottom:6px;}
12418      .hero-logo{width:42px;height:46px;}
12419      .hero-title{font-size:22px;}
12420      .hero-subtitle{font-size:12px;}
12421      .card-sections{gap:10px;}
12422      .action-card-desc{margin-bottom:4px;}
12423      .divider{margin:8px 0;}
12424      .info-strip{margin-bottom:6px;}
12425      .lan-local-hint{margin-top:10px;}
12426    }
12427  </style>
12428</head>
12429<body>
12430  <div class="background-watermarks" aria-hidden="true">
12431    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12432    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12433    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12434    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12435    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12436    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12437    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
12438  </div>
12439  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
12440  <div class="top-nav">
12441    <div class="top-nav-inner">
12442      <a class="brand" href="/">
12443        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
12444        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
12445      </a>
12446      <div class="nav-right">
12447        <a class="nav-pill" href="/">Home</a>
12448        <div class="nav-dropdown">
12449          <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>
12450          <div class="nav-dropdown-menu">
12451            <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>
12452          </div>
12453        </div>
12454        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
12455        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
12456        <div class="nav-dropdown">
12457          <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>
12458          <div class="nav-dropdown-menu">
12459            <a href="/webhook-setup"><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>
12460          </div>
12461        </div>
12462        <div class="server-status-wrap">
12463          {% if server_mode %}
12464          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12465          <div class="server-status-tip">OxideSLOC is running in server mode — accessible on your LAN.<br>Use Ctrl+C in the terminal to stop.</div>
12466          {% else %}
12467          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
12468          <div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
12469          {% endif %}
12470        </div>
12471        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12472          <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>
12473        </button>
12474        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12475          <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>
12476          <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>
12477        </button>
12478      </div>
12479    </div>
12480  </div>
12481
12482  <div class="page">
12483    <div class="hero">
12484      <div class="hero-logo-wrap" id="hero-logo-wrap">
12485        <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
12486      </div>
12487      <div class="hero-logo-shadow"></div>
12488      <div class="hero-title-wrap">
12489        <div class="hero-title-aura" aria-hidden="true"></div>
12490        <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
12491      </div>
12492      <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>
12493    </div>
12494
12495    <div class="card-sections">
12496
12497      <div>
12498        <div class="card-section-label">Analysis</div>
12499        <div class="card-section-grid-2">
12500          <a class="action-card scan card-split" href="/scan-setup">
12501            <div class="action-card-left">
12502              <div class="action-card-icon">
12503                <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
12504              </div>
12505              <div class="action-card-title">Scan Project</div>
12506              <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>
12507              <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>
12508            </div>
12509            <div class="action-card-sep"></div>
12510            <div class="action-card-right">
12511              <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>
12512              <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>
12513              <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>
12514              <div class="ac-right-stat" id="acp-scan-stat"></div>
12515            </div>
12516          </a>
12517          <a class="action-card test-metrics card-split" href="/test-metrics">
12518            <div class="action-card-left">
12519              <div class="action-card-icon">
12520                <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>
12521              </div>
12522              <div class="action-card-title">Test Metrics</div>
12523              <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>
12524              <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>
12525            </div>
12526            <div class="action-card-sep"></div>
12527            <div class="action-card-right">
12528              <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>
12529              <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>
12530              <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>
12531              <div class="ac-right-stat" id="acp-test-stat"></div>
12532            </div>
12533          </a>
12534        </div>
12535      </div>
12536
12537      <div>
12538        <div class="card-section-label">Reports &amp; Insights</div>
12539        <div class="card-section-grid-3">
12540          <a class="action-card view" href="/view-reports">
12541            <div class="action-card-icon">
12542              <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
12543            </div>
12544            <div class="action-card-title">View Reports</div>
12545            <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
12546            <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>
12547          </a>
12548          <a class="action-card compare" href="/compare-scans">
12549            <div class="action-card-icon">
12550              <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>
12551            </div>
12552            <div class="action-card-title">Compare Scans</div>
12553            <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>
12554            <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>
12555          </a>
12556          <a class="action-card trend" href="/trend-reports">
12557            <div class="action-card-icon">
12558              <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>
12559            </div>
12560            <div class="action-card-title">Trend Report</div>
12561            <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
12562            <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>
12563          </a>
12564        </div>
12565      </div>
12566
12567      <div>
12568        <div class="card-section-label">Developer Tools</div>
12569        <div class="card-section-grid-2">
12570          <a class="action-card git-tools card-split" href="/git-browser">
12571            <div class="action-card-left">
12572              <div class="action-card-icon">
12573                <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>
12574              </div>
12575              <div class="action-card-title">Git Browser</div>
12576              <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>
12577              <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>
12578            </div>
12579            <div class="action-card-sep"></div>
12580            <div class="action-card-right">
12581              <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>
12582              <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>
12583              <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>
12584            </div>
12585          </a>
12586          <a class="action-card automation card-split" href="/integrations">
12587            <div class="action-card-left">
12588              <div class="action-card-icon">
12589                <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>
12590              </div>
12591              <div class="action-card-title">Integrations</div>
12592              <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>
12593              <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>
12594            </div>
12595            <div class="action-card-sep"></div>
12596            <div class="action-card-right">
12597              <div class="ac-badges-grid">
12598                <span class="ac-badge github"     id="acp-gh">GitHub</span>
12599                <span class="ac-badge gitlab"     id="acp-gl">GitLab</span>
12600                <span class="ac-badge bitbucket"  id="acp-bb">Bitbucket</span>
12601                <span class="ac-badge confluence" id="acp-cf">Confluence</span>
12602              </div>
12603              <div class="ac-right-stat" id="acp-int-stat"></div>
12604            </div>
12605          </a>
12606        </div>
12607      </div>
12608
12609    </div>
12610
12611    {% if server_mode %}
12612    <div class="lan-card server">
12613      <div class="lan-card-header">
12614        <span class="lan-badge">LAN server</span>
12615        Accessible on your network
12616      </div>
12617      {% if let Some(ip) = lan_ip %}
12618      <div class="lan-url-row">
12619        <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
12620        <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
12621          <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>
12622          Copy URL
12623        </button>
12624      </div>
12625      <p class="lan-hint">Share this address with anyone on the same network. They will be asked to authenticate.</p>
12626      <div class="lan-auth-row">curl -H &quot;Authorization: Bearer $SLOC_API_KEY&quot; http://{{ ip }}:{{ port }}/healthz</div>
12627      {% else %}
12628      <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>.</p>
12629      {% endif %}
12630    </div>
12631    {% endif %}
12632
12633    <div class="divider"></div>
12634
12635    <div class="info-strip">
12636      <div class="info-chip">
12637        <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 29 more</div>
12638        <div class="chip-slide">
12639          <div class="info-chip-val">41</div>
12640          <div class="info-chip-label">Languages</div>
12641        </div>
12642      </div>
12643      <div class="info-chip">
12644        <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
12645        <div class="chip-slide">
12646          <div class="info-chip-val">100%</div>
12647          <div class="info-chip-label">Self-contained</div>
12648        </div>
12649      </div>
12650      <div class="info-chip">
12651        <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
12652        <div class="chip-slide">
12653          <div class="info-chip-val">HTML+PDF</div>
12654          <div class="info-chip-label">Exportable reports</div>
12655        </div>
12656      </div>
12657      <div class="info-chip">
12658        <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
12659        <div class="chip-slide">
12660          <div class="info-chip-val">Webhook</div>
12661          <div class="info-chip-label">3 platforms</div>
12662        </div>
12663      </div>
12664      <div class="info-chip">
12665        <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
12666        <div class="chip-slide">
12667          <div class="info-chip-val">IEEE</div>
12668          <div class="info-chip-label">1045-1992</div>
12669        </div>
12670      </div>
12671    </div>
12672
12673    {% if lan_ip.is_none() %}
12674    <div class="lan-local-hint">
12675      <strong>Want teammates on the same network to access this?</strong><br>
12676      Relaunch in server mode: <code>oxide-sloc serve --server</code> &nbsp;or&nbsp; <code>bash scripts/serve-server.sh</code>
12677    </div>
12678    {% endif %}
12679  </div>
12680
12681  <footer class="site-footer">
12682    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
12683    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
12684    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
12685    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
12686    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
12687  </footer>
12688
12689  <script nonce="{{ csp_nonce }}">
12690    (function () {
12691      var storageKey = 'oxide-sloc-theme';
12692      var body = document.body;
12693      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
12694      var toggle = document.getElementById('theme-toggle');
12695      if (toggle) toggle.addEventListener('click', function () {
12696        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
12697        body.classList.toggle('dark-theme', next === 'dark');
12698        try { localStorage.setItem(storageKey, next); } catch(e) {}
12699      });
12700      var copyBtn = document.getElementById('lan-copy-btn');
12701      if (copyBtn) copyBtn.addEventListener('click', function() {
12702        var btn = this;
12703        var el = document.getElementById('lan-url-val');
12704        if (!el) return;
12705        var url = el.textContent.trim();
12706        if (navigator.clipboard) {
12707          navigator.clipboard.writeText(url).then(function() {
12708            var orig = btn.innerHTML;
12709            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!';
12710            setTimeout(function() { btn.innerHTML = orig; }, 1800);
12711          });
12712        }
12713      });
12714      (function randomizeWatermarks() {
12715        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12716        if (!wms.length) return;
12717        var placed = [];
12718        function tooClose(top, left) {
12719          for (var i = 0; i < placed.length; i++) {
12720            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
12721            if (dt < 16 && dl < 12) return true;
12722          }
12723          return false;
12724        }
12725        function pick(leftBand) {
12726          for (var attempt = 0; attempt < 50; attempt++) {
12727            var top = Math.random() * 88 + 2;
12728            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12729            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
12730          }
12731          var top = Math.random() * 88 + 2;
12732          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
12733          placed.push([top, left]); return [top, left];
12734        }
12735        var half = Math.floor(wms.length / 2);
12736        wms.forEach(function (img, i) {
12737          var pos = pick(i < half);
12738          var size = Math.floor(Math.random() * 100 + 120);
12739          var rot = (Math.random() * 360).toFixed(1);
12740          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
12741          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;
12742        });
12743      })();
12744
12745      (function spawnCodeParticles() {
12746        var container = document.getElementById('code-particles');
12747        if (!container) return;
12748        var snippets = [
12749          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
12750          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
12751          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
12752          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
12753          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
12754        ];
12755        var count = 38;
12756        for (var i = 0; i < count; i++) {
12757          (function(idx) {
12758            var el = document.createElement('span');
12759            el.className = 'code-particle';
12760            var text = snippets[idx % snippets.length];
12761            el.textContent = text;
12762            var left = Math.random() * 94 + 2;
12763            var top = Math.random() * 88 + 6;
12764            var dur = (Math.random() * 10 + 9).toFixed(1);
12765            var delay = (Math.random() * 18).toFixed(1);
12766            var rot = (Math.random() * 26 - 13).toFixed(1);
12767            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
12768            el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
12769              + '--rot:' + rot + 'deg;--op:' + op + ';'
12770              + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
12771            container.appendChild(el);
12772          })(i);
12773        }
12774      })();
12775      (function heroAnimations() {
12776        var sub = document.getElementById('hero-subtitle');
12777        if (sub) {
12778          var full = sub.textContent.trim();
12779          sub.textContent = '';
12780          sub.style.opacity = '1';
12781          var cursor = document.createElement('span');
12782          cursor.className = 'hero-cursor';
12783          sub.appendChild(cursor);
12784          var i = 0;
12785          setTimeout(function() {
12786            var iv = setInterval(function() {
12787              if (i < full.length) {
12788                sub.insertBefore(document.createTextNode(full[i]), cursor);
12789                i++;
12790              } else {
12791                clearInterval(iv);
12792                setTimeout(function() {
12793                  cursor.style.transition = 'opacity 1s ease';
12794                  cursor.style.opacity = '0';
12795                  setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
12796                }, 2400);
12797              }
12798            }, 11);
12799          }, 374);
12800        }
12801      })();
12802      (function logoBob() {
12803        var logo = document.querySelector('.hero-logo');
12804        var shadow = document.querySelector('.hero-logo-shadow');
12805        if (!logo) return;
12806        var cycleStart = null, cycleDur = 3600;
12807        var peakY = -14, peakScale = 1.07, peakRot = 0;
12808        function newCycle() {
12809          cycleDur = 3000 + Math.random() * 1840;
12810          peakY = -(9 + Math.random() * 13.8);
12811          peakScale = 1.04 + Math.random() * 0.081;
12812          peakRot = (Math.random() * 11.5 - 5.75);
12813        }
12814        function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
12815        newCycle();
12816        function frame(ts) {
12817          if (cycleStart === null) cycleStart = ts;
12818          var t = (ts - cycleStart) / cycleDur;
12819          if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
12820          var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
12821          var y = peakY * phase;
12822          var sc = 1 + (peakScale - 1) * phase;
12823          var rot = peakRot * Math.sin(Math.PI * phase);
12824          logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
12825          if (shadow) {
12826            shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
12827            shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
12828          }
12829          requestAnimationFrame(frame);
12830        }
12831        requestAnimationFrame(frame);
12832      })();
12833      (function mouseEffects() {
12834        var heroTitle = document.getElementById('hero-title');
12835        var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
12836        function tick() {
12837          raf = null;
12838          if (heroTitle) {
12839            var r = heroTitle.getBoundingClientRect();
12840            var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
12841            var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
12842            heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
12843          }
12844        }
12845        document.addEventListener('mousemove', function(e) {
12846          mx = e.clientX; my = e.clientY;
12847          if (!raf) raf = requestAnimationFrame(tick);
12848        });
12849        document.addEventListener('mouseleave', function() {
12850          if (heroTitle) {
12851            heroTitle.style.transition = 'transform 0.5s ease';
12852            heroTitle.style.transform = '';
12853            setTimeout(function() { heroTitle.style.transition = ''; }, 500);
12854          }
12855        });
12856        document.querySelectorAll('.action-card').forEach(function(card) {
12857          card.addEventListener('mousemove', function(e) {
12858            var rect = card.getBoundingClientRect();
12859            var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
12860            var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
12861            card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
12862            card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
12863          });
12864          card.addEventListener('mouseleave', function() {
12865            card.style.transition = '';
12866            card.style.transform = '';
12867          });
12868        });
12869      })();
12870      (function chipSlideshow() {
12871        var slides = [
12872          [{v:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
12873          [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
12874          [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
12875          [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
12876          [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
12877        ];
12878        var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
12879        var indices = [0,0,0,0,0];
12880        var paused = [false,false,false,false,false];
12881        chips.forEach(function(chip, i) {
12882          chip.addEventListener('mouseenter', function() { paused[i] = true; });
12883          chip.addEventListener('mouseleave', function() { paused[i] = false; });
12884        });
12885        function advance(i) {
12886          if (paused[i]) return;
12887          var chip = chips[i];
12888          var inner = chip.querySelector('.chip-slide');
12889          if (!inner) return;
12890          inner.classList.add('fading');
12891          setTimeout(function() {
12892            indices[i] = (indices[i] + 1) % slides[i].length;
12893            var s = slides[i][indices[i]];
12894            chip.querySelector('.info-chip-val').textContent = s.v;
12895            chip.querySelector('.info-chip-label').textContent = s.l;
12896            inner.classList.remove('fading');
12897          }, 720);
12898        }
12899        setInterval(function() {
12900          chips.forEach(function(chip, i) { advance(i); });
12901        }, 6000);
12902      })();
12903      (function cardLiveData() {
12904        fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
12905          var el = document.getElementById('acp-scan-stat');
12906          if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
12907        }).catch(function(){});
12908        fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
12909          var el = document.getElementById('acp-test-stat');
12910          if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
12911        }).catch(function(){});
12912        fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
12913          var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
12914          var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
12915          if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
12916          if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
12917          if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
12918          var stat = document.getElementById('acp-int-stat');
12919          if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
12920        }).catch(function(){});
12921        fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
12922          if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
12923        }).catch(function(){});
12924      })();
12925    })();
12926  </script>
12927  <script nonce="{{ csp_nonce }}">
12928  (function(){
12929    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'}];
12930    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);});}
12931    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
12932    function init(){
12933      var btn=document.getElementById('settings-btn');if(!btn)return;
12934      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12935      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
12936      document.body.appendChild(m);
12937      var g=document.getElementById('scheme-grid');
12938      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);});
12939      var cl=document.getElementById('settings-close');
12940      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
12941      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');});
12942      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
12943      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
12944    }
12945    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
12946  }());
12947  </script>
12948</body>
12949</html>
12950"##,
12951    ext = "html"
12952)]
12953struct SplashTemplate {
12954    csp_nonce: String,
12955    server_mode: bool,
12956    lan_ip: Option<String>,
12957    port: u16,
12958    version: &'static str,
12959}
12960
12961// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
12962
12963#[derive(Template)]
12964#[template(
12965    source = r##"
12966<!doctype html>
12967<html lang="en">
12968<head>
12969  <meta charset="utf-8">
12970  <meta name="viewport" content="width=device-width, initial-scale=1">
12971  <title>OxideSLOC — Start a Scan</title>
12972  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
12973  <style nonce="{{ csp_nonce }}">
12974    :root {
12975      --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
12976      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
12977      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
12978      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
12979      --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
12980    }
12981    body.dark-theme {
12982      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
12983      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
12984    }
12985    *{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);}
12986    .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);}
12987    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
12988    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
12989    .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));}
12990    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
12991    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
12992    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
12993    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
12994    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
12995    @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; } }
12996    .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;}
12997    a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
12998    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
12999    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
13000    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
13001    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
13002    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
13003    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13004    .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);}
13005    .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;}
13006    .settings-close:hover{color:var(--text);background:var(--surface-2);}
13007    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13008    .settings-modal-body{padding:14px 16px 16px;}
13009    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13010    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13011    .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;}
13012    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13013    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13014    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13015    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13016    .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;}
13017    .tz-select:focus{border-color:var(--oxide);}
13018    .page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
13019    .page-header{text-align:center;margin-bottom:16px;}
13020    .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
13021    .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
13022    /* Cards */
13023    .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
13024    .option-card-wrap{position:relative;}
13025    .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;}
13026    .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
13027    @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
13028    .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;}
13029    .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
13030    .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
13031    #recent-card{flex-direction:column;align-items:stretch;gap:0;}
13032    .card-top-row{display:flex;align-items:center;gap:20px;}
13033    /* Two-column layout inside each card */
13034    .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
13035    .card-left{display:flex;align-items:flex-start;min-width:0;}
13036    .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
13037    .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
13038    .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);}
13039    .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);}
13040    .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);}
13041    .card-text{min-width:0;}
13042    .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
13043    .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
13044    .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
13045    .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
13046    .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
13047    /* Right CTA column */
13048    .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
13049    .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;}
13050    /* Re-scan count badge */
13051    .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
13052    .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
13053    .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
13054    body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
13055    .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
13056    .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
13057    .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
13058    body.dark-theme .btn-secondary{color:var(--oxide);}
13059    .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
13060    .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
13061    /* File input overlay — must be full-width so it aligns with other card-right buttons */
13062    .file-input-wrap{position:relative;width:100%;}
13063    .file-input-wrap .btn{width:100%;}
13064    .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
13065    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13066    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
13067    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
13068    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
13069    @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));}}
13070    /* Recent list (card 3 — full-width section below header) */
13071    .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
13072    .recent-list{display:flex;flex-direction:column;gap:8px;}
13073    .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;}
13074    .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
13075    .recent-item-info{flex:1;min-width:0;}
13076    .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
13077    .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
13078    .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
13079    .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
13080    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13081    .site-footer a{color:var(--muted);}
13082    @media(max-width:680px){
13083      .card-body{grid-template-columns:1fr;}
13084      .card-right{flex-direction:row;flex-wrap:wrap;}
13085      .btn{flex:1;}
13086    }
13087    .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;}
13088    .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;}
13089    .server-online-pill{cursor:default;}
13090  </style>
13091</head>
13092<body>
13093  <div class="background-watermarks" aria-hidden="true">
13094    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13095    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13096    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13097    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13098    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13099    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13100    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
13101  </div>
13102  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13103  <div class="top-nav">
13104    <div class="top-nav-inner">
13105      <a class="brand" href="/">
13106        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13107        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13108      </a>
13109      <div class="nav-right">
13110        <a class="nav-pill" href="/">Home</a>
13111        <div class="nav-dropdown">
13112          <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>
13113          <div class="nav-dropdown-menu">
13114            <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>
13115          </div>
13116        </div>
13117        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
13118        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13119        <div class="nav-dropdown">
13120          <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>
13121          <div class="nav-dropdown-menu">
13122            <a href="/webhook-setup"><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>
13123          </div>
13124        </div>
13125        <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13126        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13127          <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>
13128        </button>
13129        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13130          <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>
13131          <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>
13132        </button>
13133      </div>
13134    </div>
13135  </div>
13136
13137  <div class="page">
13138    <div class="page-header">
13139      <h1>How would you like to scan?</h1>
13140      <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
13141    </div>
13142
13143    <div class="option-grid">
13144
13145      <!-- Option 1: New scan -->
13146      <div class="option-card-wrap">
13147        <div class="option-card">
13148        <div class="option-icon new-scan">
13149          <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
13150        </div>
13151        <div class="card-body">
13152          <div class="card-left">
13153            <div class="card-text">
13154              <div class="option-title">Start a new scan</div>
13155              <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>
13156              <ul class="feature-list">
13157                <li>Live project scope preview before you run</li>
13158                <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
13159                <li>HTML, PDF, and JSON output — your choice</li>
13160              </ul>
13161            </div>
13162          </div>
13163          <div class="card-right">
13164            <a class="btn btn-primary" href="/scan">
13165              Configure &amp; scan
13166              <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
13167            </a>
13168            <p class="card-tip">Full 4-step setup · all options</p>
13169          </div>
13170        </div>
13171        </div>
13172      </div>
13173
13174      <!-- Option 2: Load from config file -->
13175      <div class="option-card-wrap">
13176        <div class="option-card">
13177        <div class="option-icon load-config">
13178          <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>
13179        </div>
13180        <div class="card-body">
13181          <div class="card-left">
13182            <div class="card-text">
13183              <div class="option-title">Load a saved config</div>
13184              <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>
13185              <ul class="feature-list">
13186                <li>All 15 settings restored from the file</li>
13187                <li>Fully editable — change path or output dir</li>
13188                <li>Works with any scan-config.json</li>
13189              </ul>
13190            </div>
13191          </div>
13192          <div class="card-right">
13193            <div class="file-input-wrap">
13194              <button class="btn btn-secondary" id="load-config-btn" type="button">
13195                <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>
13196                Choose config file
13197              </button>
13198              <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
13199            </div>
13200            <p class="card-tip" id="config-file-name">Exported after every scan</p>
13201          </div>
13202        </div>
13203        </div>
13204      </div>
13205
13206      <!-- Option 3: Re-scan recent project -->
13207      <div class="option-card-wrap">
13208        <div class="option-card" id="recent-card">
13209        <div class="card-top-row">
13210          <div class="option-icon rescan">
13211            <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>
13212          </div>
13213          <div class="card-body">
13214            <div class="card-left">
13215              <div class="card-text">
13216                <div class="option-title">Re-scan a recent project</div>
13217                <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>
13218                <ul class="feature-list">
13219                  <li>All 15+ settings restored from the saved config</li>
13220                  <li>Path and output dir are editable before running</li>
13221                  <li>Only scans with a saved config appear here</li>
13222                </ul>
13223              </div>
13224            </div>
13225            <div class="card-right">
13226              <div class="rescan-count-box">
13227                <div class="rescan-count-num" id="rescan-count-num">—</div>
13228                <div class="rescan-count-label">saved configs</div>
13229              </div>
13230              <a class="btn btn-secondary" href="/view-reports">
13231                <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>
13232                View all runs
13233              </a>
13234              <p class="card-tip">Opens run history</p>
13235            </div>
13236          </div>
13237        </div>
13238        <div class="section-divider"></div>
13239        <div class="recent-list" id="recent-list">
13240          <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
13241        </div>
13242        </div>
13243      </div>
13244
13245    </div>
13246  </div>
13247
13248  <footer class="site-footer">
13249    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
13250    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13251    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13252    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13253    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
13254  </footer>
13255
13256  <script nonce="{{ csp_nonce }}">
13257    (function () {
13258      var storageKey = 'oxide-sloc-theme';
13259      var body = document.body;
13260      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
13261      var toggle = document.getElementById('theme-toggle');
13262      if (toggle) toggle.addEventListener('click', function () {
13263        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
13264        body.classList.toggle('dark-theme', next === 'dark');
13265        try { localStorage.setItem(storageKey, next); } catch(e) {}
13266      });
13267
13268      (function randomizeWatermarks() {
13269        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13270        if (!wms.length) return;
13271        var placed = [];
13272        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; }
13273        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]; }
13274        var half = Math.floor(wms.length / 2);
13275        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; });
13276      })();
13277      (function spawnCodeParticles() {
13278        var container = document.getElementById('code-particles');
13279        if (!container) return;
13280        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
13281        var count = 38;
13282        for (var i = 0; i < count; i++) { (function(idx) { var el = document.createElement('span'); el.className = 'code-particle'; el.textContent = snippets[idx % snippets.length]; var left = Math.random() * 94 + 2; var top = Math.random() * 88 + 6; var dur = (Math.random() * 10 + 9).toFixed(1); var delay = (Math.random() * 18).toFixed(1); var rot = (Math.random() * 26 - 13).toFixed(1); var op = (Math.random() * 0.09 + 0.06).toFixed(3); el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s'; container.appendChild(el); })(i); }
13283      })();
13284      // Recent scans data injected from server
13285      var recentScans = {{ recent_scans_json|safe }};
13286
13287      function configToParams(cfg) {
13288        var p = new URLSearchParams();
13289        p.set('prefilled', '1');
13290        if (cfg.path) p.set('path', cfg.path);
13291        if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
13292        if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
13293        if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
13294        p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
13295        p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
13296        p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
13297        p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
13298        p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
13299        if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
13300        p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
13301        if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
13302        if (cfg.report_title) p.set('report_title', cfg.report_title);
13303        p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
13304        if (cfg.generate_pdf) p.set('generate_pdf', 'on');
13305        return p;
13306      }
13307
13308      // Build recent scan list (capped at 3 visible entries)
13309      var list = document.getElementById('recent-list');
13310      var noNote = document.getElementById('no-recent-note');
13311      var hasAny = false;
13312      var MAX_RECENT = 3;
13313      if (Array.isArray(recentScans)) {
13314        var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
13315        var shown = 0;
13316        validEntries.forEach(function (entry) {
13317          if (shown >= MAX_RECENT) return;
13318          shown++;
13319          hasAny = true;
13320          var item = document.createElement('div');
13321          item.className = 'recent-item';
13322          item.title = 'Restore all settings and open wizard';
13323          item.innerHTML =
13324            '<div class="recent-item-info">' +
13325              '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
13326              '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' &nbsp;·&nbsp; ' + escHtml(entry.timestamp || '') + '</div>' +
13327            '</div>' +
13328            '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
13329          item.addEventListener('click', function () {
13330            var params = configToParams(entry.config);
13331            window.location.href = '/scan?' + params.toString();
13332          });
13333          list.appendChild(item);
13334        });
13335        if (validEntries.length > MAX_RECENT) {
13336          var moreEl = document.createElement('div');
13337          moreEl.className = 'recent-more-link';
13338          moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more &mdash; <a href="/view-reports">view all runs</a>';
13339          list.appendChild(moreEl);
13340        }
13341      }
13342      if (hasAny && noNote) noNote.style.display = 'none';
13343      // Update count badge
13344      var countEl = document.getElementById('rescan-count-num');
13345      if (countEl) {
13346        var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
13347        countEl.textContent = total > 0 ? total : '0';
13348      }
13349
13350      // Config file loader
13351      var fileInput = document.getElementById('config-file-input');
13352      var fileName = document.getElementById('config-file-name');
13353      if (fileInput) {
13354        fileInput.addEventListener('change', function () {
13355          var file = fileInput.files && fileInput.files[0];
13356          if (!file) return;
13357          if (fileName) fileName.textContent = '✓ ' + file.name;
13358          var reader = new FileReader();
13359          reader.onload = function (e) {
13360            try {
13361              var cfg = JSON.parse(e.target.result);
13362              if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
13363              var params = configToParams(cfg);
13364              window.location.href = '/scan?' + params.toString();
13365            } catch (err) {
13366              alert('Could not parse config file: ' + err.message);
13367            }
13368          };
13369          reader.readAsText(file);
13370        });
13371      }
13372
13373      function escHtml(s) {
13374        return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
13375      }
13376    })();
13377  </script>
13378  <script nonce="{{ csp_nonce }}">
13379  (function(){
13380    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'}];
13381    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);});}
13382    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
13383    function init(){
13384      var btn=document.getElementById('settings-btn');if(!btn)return;
13385      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13386      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
13387      document.body.appendChild(m);
13388      var g=document.getElementById('scheme-grid');
13389      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);});
13390      var cl=document.getElementById('settings-close');
13391      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
13392      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');});
13393      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
13394      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
13395    }
13396    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13397  }());
13398  </script>
13399</body>
13400</html>
13401"##,
13402    ext = "html"
13403)]
13404struct ScanSetupTemplate {
13405    version: &'static str,
13406    recent_scans_json: String,
13407    csp_nonce: String,
13408}
13409
13410#[derive(Template)]
13411#[template(
13412    source = r##"
13413<!doctype html>
13414<html lang="en">
13415<head>
13416  <meta charset="utf-8">
13417  <meta name="viewport" content="width=device-width, initial-scale=1">
13418  <title>OxideSLOC | {{ report_title }} | Report</title>
13419  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13420  <style nonce="{{ csp_nonce }}">
13421    :root {
13422      --radius: 18px;
13423      --bg: #f5efe8;
13424      --surface: rgba(255,255,255,0.82);
13425      --surface-2: #fbf7f2;
13426      --surface-3: #efe6dc;
13427      --line: #e6d0bf;
13428      --line-strong: #dcb89f;
13429      --text: #43342d;
13430      --muted: #7b675b;
13431      --muted-2: #a08777;
13432      --nav: #b85d33;
13433      --nav-2: #7a371b;
13434      --accent: #6f9bff;
13435      --accent-2: #4a78ee;
13436      --oxide: #d37a4c;
13437      --oxide-2: #b35428;
13438      --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
13439      --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
13440      --success-bg: #e8f5ed;
13441      --success-text: #1a8f47;
13442      --info-bg: #eef3ff;
13443      --info-text: #4467d8;
13444    }
13445
13446    body.dark-theme {
13447      --bg: #1b1511;
13448      --surface: #261c17;
13449      --surface-2: #2d221d;
13450      --surface-3: #372922;
13451      --line: #524238;
13452      --line-strong: #6c5649;
13453      --text: #f5ece6;
13454      --muted: #c7b7aa;
13455      --muted-2: #aa9485;
13456      --nav: #b85d33;
13457      --nav-2: #7a371b;
13458      --accent: #6f9bff;
13459      --accent-2: #4a78ee;
13460      --oxide: #d37a4c;
13461      --oxide-2: #b35428;
13462      --shadow: 0 18px 42px rgba(0,0,0,0.28);
13463      --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
13464      --success-bg: #163927;
13465      --success-text: #8fe2a8;
13466      --info-bg: #1c2847;
13467      --info-text: #a9c1ff;
13468    }
13469
13470    * { box-sizing: border-box; }
13471    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); }
13472    body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
13473    .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
13474    .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
13475    .top-nav, .page { position: relative; z-index: 2; }
13476    .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); }
13477    .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; }
13478    .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
13479    .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)); }
13480    .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; }
13481    .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
13482    .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
13483    .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
13484    .nav-project-slot { display:flex; justify-content:center; min-width:0; }
13485    .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; }
13486    .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
13487    .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
13488    .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
13489    @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
13490    @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; } }
13491    .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; }
13492    .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
13493    .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
13494    .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
13495    .theme-toggle .icon-sun { display:none; }
13496    body.dark-theme .theme-toggle .icon-sun { display:block; }
13497    body.dark-theme .theme-toggle .icon-moon { display:none; }
13498    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
13499    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
13500    .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);}
13501    .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;}
13502    .settings-close:hover{color:var(--text);background:var(--surface-2);}
13503    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
13504    .settings-modal-body{padding:14px 16px 16px;}
13505    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
13506    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
13507    .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;}
13508    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
13509    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
13510    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
13511    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
13512    .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;}
13513    .tz-select:focus{border-color:var(--oxide);}
13514    .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; }
13515    .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;}
13516    .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
13517    .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
13518    .hero, .panel { padding: 22px; }
13519    .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
13520    .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
13521    .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
13522    .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
13523    .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; }
13524    .compare-banner-body { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
13525    .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
13526    .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
13527    .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
13528    .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
13529    .delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
13530    .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; min-width:92px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; }
13531    .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
13532    .delta-card-val { font-size:16px; font-weight:800; }
13533    .delta-card-val.pos { color:#1e7e34; }
13534    .delta-card-val.neg { color:var(--neg); }
13535    .delta-card-val.mod { color:#b35428; }
13536    .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
13537    .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); 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 .2s ease; z-index:200; }
13538    .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13539    .delta-card-inline:hover .delta-card-tip { opacity:1; }
13540    .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
13541    .compare-ts { font-size:13px; color:var(--muted); }
13542    .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
13543    .compare-arrow { color: var(--muted); }
13544    .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
13545    .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; }
13546    .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
13547    .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
13548    .button, .copy-button {
13549      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;
13550    }
13551    .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
13552    @keyframes spin { to { transform: rotate(360deg); } }
13553    .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
13554    .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
13555    .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
13556    .path-item strong { display: block; margin-bottom: 6px; }
13557    .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
13558    .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
13559    .path-subitem { flex: 1; }
13560    .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); }
13561    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); }
13562    .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
13563    table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
13564    th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
13565    .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
13566    th { color: var(--muted); font-weight: 700; }
13567    tr:last-child td { border-bottom: none; }
13568    #subm-tbl col:nth-child(1){width:15%;}
13569    #subm-tbl col:nth-child(2){width:31%;}
13570    #subm-tbl col:nth-child(3){width:9%;}
13571    #subm-tbl col:nth-child(4){width:9%;}
13572    #subm-tbl col:nth-child(5){width:9%;}
13573    #subm-tbl col:nth-child(6){width:9%;}
13574    #subm-tbl col:nth-child(7){width:9%;}
13575    #subm-tbl col:nth-child(8){width:9%;}
13576    .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
13577    iframe { width: 100%; min-height: 1000px; border: none; background: white; }
13578    .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
13579    .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
13580    .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
13581    .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
13582    .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; }
13583    .soft-chip.success { gap:7px; padding:0 16px 0 12px; background:linear-gradient(135deg,rgba(26,143,71,0.12),rgba(26,143,71,0.06)); color:var(--success-text); border:1.5px solid rgba(26,143,71,0.35); box-shadow:0 0 0 4px rgba(26,143,71,0.07),0 2px 8px rgba(26,143,71,0.12); font-size:12px; letter-spacing:0.02em; }
13584    .soft-chip.success svg { flex:0 0 auto; }
13585    body.dark-theme .soft-chip.success { background:linear-gradient(135deg,rgba(143,226,168,0.12),rgba(143,226,168,0.05)); border-color:rgba(143,226,168,0.3); box-shadow:0 0 0 4px rgba(143,226,168,0.07),0 2px 8px rgba(0,0,0,0.2); }
13586    .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
13587    .muted { color: var(--muted); }
13588    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
13589    .site-footer a{color:var(--muted);}
13590    .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; }
13591    .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
13592    .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; }
13593    .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
13594    /* Stat chips (matches HTML report) */
13595    .summary-strip { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-top:18px; }
13596    @media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,1fr);}}
13597    @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
13598    .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .2s ease,box-shadow .2s ease; overflow:visible; }
13599    .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
13600    .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
13601    .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
13602    .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; }
13603    .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:7px 12px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .2s ease; z-index:200; }
13604    .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
13605    .stat-chip:hover .stat-chip-tip { opacity:1; }
13606    /* Submodule panel */
13607    .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
13608    /* Metrics tables stack */
13609    .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
13610    .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
13611    @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
13612    .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)); }
13613    .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
13614    /* Metrics table */
13615    .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
13616    .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
13617    .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; }
13618    .metrics-table thead th:not(:first-child) { text-align: right; }
13619    .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
13620    .metrics-table tbody tr:last-child td { border-bottom: none; }
13621    .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
13622    .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
13623    .metrics-table tbody tr:hover td { background: var(--surface-2); }
13624    .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
13625    .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; }
13626    .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
13627    .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
13628    .mt-val-pos { color: var(--pos); font-weight: 700; }
13629    .mt-val-neg { color: var(--neg); font-weight: 700; }
13630    .mt-val-zero { color: var(--muted); }
13631    .mt-val-mod { color: var(--oxide-2); }
13632    .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
13633    @media (max-width: 1180px) {
13634      .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
13635      .nav-project-slot, .nav-status { justify-content:flex-start; }
13636      .hero-top { flex-direction: column; }
13637    }
13638    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
13639    @keyframes floatCode{0%{opacity:0;transform:translateY(0) rotate(var(--rot));}10%{opacity:var(--op);}85%{opacity:var(--op);}100%{opacity:0;transform:translateY(-200px) rotate(var(--rot));}}
13640    .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;}
13641    /* ── Result-page chart controls ─────────────────────────────────────────── */
13642    .r-chart-section{margin-bottom:24px;}
13643    .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
13644    .section-pair > .panel{flex-shrink:0;}
13645    .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
13646    .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;}
13647    .r-chart-select:focus{border-color:var(--accent);}
13648    .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
13649    .r-chart-container svg{display:block;width:100%;height:auto;}
13650    .r-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
13651    .r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
13652    .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
13653    .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;}
13654    .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
13655    .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
13656    @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
13657    @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
13658    #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:9999;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
13659    .r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
13660    .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
13661    .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;}
13662    .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
13663    @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
13664    .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface-2);display:flex;flex-direction:column;}
13665    .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
13666    .report-id-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;text-align:center;height:27px;line-height:27px;padding:0 16px;position:fixed;top:0;left:0;right:0;z-index:32;width:100%;}
13667    .report-id-footer-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;text-align:center;height:27px;line-height:27px;padding:0 16px;position:fixed;bottom:0;left:0;right:0;z-index:32;width:100%;}
13668    body.has-report-banner .top-nav{top:27px;}
13669    body.has-report-banner{padding-bottom:27px;}
13670  </style>
13671</head>
13672<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
13673  <div class="background-watermarks" aria-hidden="true">
13674    <img src="/images/logo/logo-text.png" alt="" />
13675    <img src="/images/logo/logo-text.png" alt="" />
13676    <img src="/images/logo/logo-text.png" alt="" />
13677    <img src="/images/logo/logo-text.png" alt="" />
13678    <img src="/images/logo/logo-text.png" alt="" />
13679    <img src="/images/logo/logo-text.png" alt="" />
13680    <img src="/images/logo/logo-text.png" alt="" />
13681    <img src="/images/logo/logo-text.png" alt="" />
13682    <img src="/images/logo/logo-text.png" alt="" />
13683    <img src="/images/logo/logo-text.png" alt="" />
13684    <img src="/images/logo/logo-text.png" alt="" />
13685    <img src="/images/logo/logo-text.png" alt="" />
13686    <img src="/images/logo/logo-text.png" alt="" />
13687    <img src="/images/logo/logo-text.png" alt="" />
13688  </div>
13689  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13690  {% if let Some(banner) = report_header_footer %}
13691  <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
13692  {% endif %}
13693  <div class="top-nav">
13694    <div class="top-nav-inner">
13695      <a class="brand" href="/">
13696        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13697        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
13698      </a>
13699      <div class="nav-project-slot">
13700        <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
13701      </div>
13702      <div class="nav-status">
13703        <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
13704        <div class="nav-dropdown">
13705          <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>
13706          <div class="nav-dropdown-menu">
13707            <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>
13708          </div>
13709        </div>
13710        <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
13711        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13712        <div class="nav-dropdown">
13713          <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>
13714          <div class="nav-dropdown-menu">
13715            <a href="/webhook-setup"><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>
13716          </div>
13717        </div>
13718        <div class="server-status-wrap">
13719          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
13720          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
13721        </div>
13722        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13723          <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>
13724        </button>
13725        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
13726          <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>
13727          <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>
13728        </button>
13729      </div>
13730    </div>
13731  </div>
13732
13733  <div class="page">
13734    <section class="hero">
13735      <div class="hero-top">
13736        <div>
13737          <div class="soft-chip success"><svg width="13" height="13" 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>
13738          <h1 class="hero-title">{{ report_title }}</h1>
13739          <p class="hero-subtitle">Your HTML, PDF, and JSON artifacts are now saved. Use the quick actions below to view, download, or copy the saved paths for sharing outside oxide-sloc.</p>
13740        </div>
13741        <div class="hero-quick-actions">
13742          <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
13743          <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
13744          <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
13745        </div>
13746      </div>
13747
13748      <div class="summary-strip">
13749        <div class="stat-chip" data-raw="{{ physical_lines }}">
13750          <div class="stat-chip-label">Physical Lines</div>
13751          <div class="stat-chip-val">{{ physical_lines }}</div>
13752          <div class="stat-chip-exact"></div>
13753          <div class="stat-chip-tip">Total physical lines including code, comments, and blank lines</div>
13754        </div>
13755        <div class="stat-chip" data-raw="{{ code_lines }}">
13756          <div class="stat-chip-label">Code</div>
13757          <div class="stat-chip-val">{{ code_lines }}</div>
13758          <div class="stat-chip-exact"></div>
13759          <div class="stat-chip-tip">Executable source lines (IEEE 1045 SLOC)</div>
13760        </div>
13761        <div class="stat-chip" data-raw="{{ comment_lines }}">
13762          <div class="stat-chip-label">Comments</div>
13763          <div class="stat-chip-val">{{ comment_lines }}</div>
13764          <div class="stat-chip-exact"></div>
13765          <div class="stat-chip-tip">Lines classified as comments or documentation</div>
13766        </div>
13767        <div class="stat-chip" data-raw="{{ blank_lines }}">
13768          <div class="stat-chip-label">Blank</div>
13769          <div class="stat-chip-val">{{ blank_lines }}</div>
13770          <div class="stat-chip-exact"></div>
13771          <div class="stat-chip-tip">Empty or whitespace-only lines</div>
13772        </div>
13773        <div class="stat-chip" data-raw="{{ files_analyzed }}">
13774          <div class="stat-chip-label">Files Analyzed</div>
13775          <div class="stat-chip-val">{{ files_analyzed }}</div>
13776          <div class="stat-chip-exact"></div>
13777          <div class="stat-chip-tip">Source files successfully parsed and counted</div>
13778        </div>
13779        <div class="stat-chip" data-raw="{{ functions }}">
13780          <div class="stat-chip-label">Functions</div>
13781          <div class="stat-chip-val">{{ functions }}</div>
13782          <div class="stat-chip-exact"></div>
13783          <div class="stat-chip-tip">Best-effort count of function and method definitions</div>
13784        </div>
13785      </div>
13786
13787      {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
13788      <div class="compare-banner">
13789        <div class="compare-banner-body">
13790          <div class="compare-banner-meta">
13791            <span class="compare-label">Previous scan</span>
13792            <span class="compare-ts">{{ prev_ts }}</span>
13793            {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
13794            {% if let Some(prev_code) = prev_run_code_lines %}
13795            <div class="compare-banner-stats" style="margin-top:4px;">
13796              <span>Code before: <strong>{{ prev_code }}</strong></span>
13797              <span class="compare-arrow">→</span>
13798              <span>Code now: <strong>{{ code_lines }}</strong></span>
13799              {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
13800              {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">&minus;{{ removed }} removed</span>{% endif %}
13801            </div>
13802            {% endif %}
13803          </div>
13804          {% if delta_lines_added.is_some() %}
13805          <div class="delta-cards-inline">
13806            <div class="delta-card-inline">
13807              <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
13808              <div class="delta-card-lbl">lines added</div>
13809              <div class="delta-card-tip">Code lines added since the previous scan</div>
13810            </div>
13811            <div class="delta-card-inline">
13812              <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}&minus;{{ v }}{% else %}—{% endif %}</div>
13813              <div class="delta-card-lbl">lines removed</div>
13814              <div class="delta-card-tip">Code lines removed since the previous scan</div>
13815            </div>
13816            <div class="delta-card-inline">
13817              <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
13818              <div class="delta-card-lbl">unmodified lines</div>
13819              <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
13820            </div>
13821            <div class="delta-card-inline">
13822              <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
13823              <div class="delta-card-lbl">files modified</div>
13824              <div class="delta-card-tip">Files with at least one line changed</div>
13825            </div>
13826            <div class="delta-card-inline">
13827              <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
13828              <div class="delta-card-lbl">files added</div>
13829              <div class="delta-card-tip">New files added since the previous scan</div>
13830            </div>
13831            <div class="delta-card-inline">
13832              <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
13833              <div class="delta-card-lbl">files removed</div>
13834              <div class="delta-card-tip">Files deleted since the previous scan</div>
13835            </div>
13836            <div class="delta-card-inline">
13837              <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
13838              <div class="delta-card-lbl">files unchanged</div>
13839              <div class="delta-card-tip">Files with no changes since the previous scan</div>
13840            </div>
13841          </div>
13842          {% else %}
13843          <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
13844            Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
13845          </p>
13846          {% endif %}
13847          <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
13848        </div>
13849      </div>
13850      {% endif %}{% endif %}
13851
13852      <div class="action-grid">
13853        <div class="action-card">
13854          <h3>HTML report</h3>
13855          <div class="action-buttons">
13856            {% match html_url %}
13857              {% when Some with (url) %}
13858                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
13859              {% when None %}{% endmatch %}
13860            {% match html_download_url %}
13861              {% when Some with (url) %}
13862                <a class="button secondary" href="{{ url }}">Download HTML</a>
13863              {% when None %}{% endmatch %}
13864            {% match html_path %}
13865              {% when Some with (_path) %}{% when None %}{% endmatch %}
13866            <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
13867          </div>
13868        </div>
13869        <div class="action-card">
13870          <h3>PDF report</h3>
13871          <div class="action-buttons">
13872            {% match pdf_url %}
13873              {% when Some with (url) %}
13874                {% if pdf_generating %}
13875                  <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
13876                    <span style="width:14px;height:14px;border:2px solid rgba(255,255,255,0.4);border-top-color:#fff;border-radius:50%;display:inline-block;animation:spin .75s linear infinite;flex:0 0 auto;"></span>
13877                    Generating PDF…
13878                  </button>
13879                {% else %}
13880                  <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
13881                {% endif %}
13882              {% when None %}{% endmatch %}
13883            {% match pdf_download_url %}
13884              {% when Some with (url) %}
13885                <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
13886              {% when None %}{% endmatch %}
13887            {% match pdf_path %}
13888              {% when Some with (_path) %}{% when None %}{% endmatch %}
13889            <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
13890          </div>
13891        </div>
13892        <div class="action-card">
13893          <h3>JSON result</h3>
13894          <div class="action-buttons">
13895            {% match json_url %}
13896              {% when Some with (url) %}
13897                <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
13898              {% when None %}{% endmatch %}
13899            {% match json_download_url %}
13900              {% when Some with (url) %}
13901                <a class="button secondary" href="{{ url }}">Download JSON</a>
13902              {% when None %}{% endmatch %}
13903            {% match json_path %}
13904              {% when Some with (_path) %}
13905                <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
13906              {% when None %}
13907                <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
13908              {% endmatch %}
13909          </div>
13910        </div>
13911        <div class="action-card">
13912          <h3>Scan config</h3>
13913          <div class="action-buttons">
13914            <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
13915            <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
13916            <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
13917          </div>
13918        </div>
13919        {% if confluence_configured %}
13920        <div class="action-card" id="confluenceCard">
13921          <h3>Confluence</h3>
13922          <div class="action-buttons">
13923            <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
13924            <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
13925          </div>
13926          <p class="action-empty-note" style="margin-top:6px;">Create or update a Confluence page with this scan result, or copy wiki markup for manual paste.</p>
13927        </div>
13928        {% endif %}
13929      </div>
13930      {% if confluence_configured %}
13931      <div id="confluenceModal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.45);align-items:center;justify-content:center;">
13932        <div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:480px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">
13933          <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
13934          <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
13935          <input id="confPageTitle" type="text" value="OxideSLOC — {{ report_title }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
13936          <label style="font-size:12px;font-weight:700;color:var(--muted);">Report URL <span style="font-weight:400;">(optional — linked in page body)</span></label>
13937          <input id="confReportUrl" type="url" placeholder="http://127.0.0.1:4317/runs/result/{{ run_id }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
13938          <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
13939          <div style="display:flex;gap:10px;justify-content:flex-end;">
13940            <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
13941            <button class="button" id="confSubmitBtn" type="button">Post</button>
13942          </div>
13943        </div>
13944      </div>
13945      {% endif %}
13946      {% if !submodule_rows.is_empty() %}
13947      <div class="submodule-panel">
13948        <div class="toolbar-row">
13949          <div>
13950            <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
13951            <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
13952          </div>
13953          <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
13954        </div>
13955        <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
13956        <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
13957          <colgroup><col style="width:15%"><col style="width:31%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"></colgroup>
13958          <thead>
13959            <tr>
13960              <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Submodule</th>
13961              <th style="padding:9px 14px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:left;white-space:nowrap;">Path</th>
13962              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Files</th>
13963              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Physical</th>
13964              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Code</th>
13965              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Comments</th>
13966              <th style="padding:9px 2px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Blank</th>
13967              <th style="padding:9px 8px;background:var(--surface-2);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">Report</th>
13968            </tr>
13969          </thead>
13970          <tbody>
13971            {% for row in submodule_rows %}
13972            <tr>
13973              <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
13974              <td style="padding:10px 14px;border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;" title="{{ row.relative_path }}"><code style="font-size:12px;white-space:nowrap;word-break:keep-all;overflow-wrap:normal;">{{ row.relative_path }}</code></td>
13975              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
13976              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
13977              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
13978              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
13979              <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
13980              <td style="padding:10px 8px;border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 10px;min-height:0;display:block;margin:0 auto;width:fit-content;">View</a>{% else %}<span style="color:var(--muted);font-size:12px;">—</span>{% endif %}</td>
13981            </tr>
13982            {% endfor %}
13983          </tbody>
13984        </table>
13985        </div>
13986      </div>
13987      {% endif %}
13988
13989      <div class="metrics-tables-stack">
13990
13991        <div class="metrics-table-wrap">
13992          <div class="metrics-table-title">Files</div>
13993          <table class="metrics-table">
13994            <thead>
13995              <tr>
13996                <th>Metric</th>
13997                <th>This Run</th>
13998                <th>Previous</th>
13999                <th>Change</th>
14000              </tr>
14001            </thead>
14002            <tbody>
14003              <tr>
14004                <td>Files analyzed</td>
14005                <td class="mt-val-large">{{ files_analyzed }}</td>
14006                <td>{{ prev_fa_str }}</td>
14007                <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
14008              </tr>
14009              <tr>
14010                <td>Files skipped</td>
14011                <td>{{ files_skipped }}</td>
14012                <td>{{ prev_fs_str }}</td>
14013                <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
14014              </tr>
14015              <tr>
14016                <td>Files modified</td>
14017                <td class="mt-val-na">—</td>
14018                <td class="mt-val-na">—</td>
14019                <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14020              </tr>
14021              <tr>
14022                <td>Files unchanged</td>
14023                <td class="mt-val-na">—</td>
14024                <td class="mt-val-na">—</td>
14025                <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
14026              </tr>
14027            </tbody>
14028          </table>
14029        </div>
14030
14031        <div class="metrics-table-wrap">
14032          <div class="metrics-table-title">Line Counts</div>
14033          <table class="metrics-table">
14034            <thead>
14035              <tr>
14036                <th>Metric</th>
14037                <th>This Run</th>
14038                <th>Previous</th>
14039                <th>Change</th>
14040              </tr>
14041            </thead>
14042            <tbody>
14043              <tr>
14044                <td>Physical lines</td>
14045                <td class="mt-val-large">{{ physical_lines }}</td>
14046                <td>{{ prev_pl_str }}</td>
14047                <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
14048              </tr>
14049              <tr>
14050                <td>Code lines</td>
14051                <td class="mt-val-large">{{ code_lines }}</td>
14052                <td>{{ prev_cl_str }}</td>
14053                <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
14054              </tr>
14055              <tr>
14056                <td>Comment lines</td>
14057                <td>{{ comment_lines }}</td>
14058                <td>{{ prev_cml_str }}</td>
14059                <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
14060              </tr>
14061              <tr>
14062                <td>Blank lines</td>
14063                <td>{{ blank_lines }}</td>
14064                <td>{{ prev_bl_str }}</td>
14065                <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
14066              </tr>
14067              <tr>
14068                <td>Mixed (separate)</td>
14069                <td>{{ mixed_lines }}</td>
14070                <td class="mt-val-na">—</td>
14071                <td class="mt-val-na">—</td>
14072              </tr>
14073            </tbody>
14074          </table>
14075        </div>
14076
14077        <div class="metrics-tables-lower">
14078          <div class="metrics-table-wrap">
14079            <div class="metrics-table-title">Code Structure</div>
14080            <table class="metrics-table">
14081              <thead>
14082                <tr>
14083                  <th>Metric</th>
14084                  <th>This Run</th>
14085                </tr>
14086              </thead>
14087              <tbody>
14088                <tr>
14089                  <td>Functions</td>
14090                  <td>{{ functions }}</td>
14091                </tr>
14092                <tr>
14093                  <td>Classes / Types</td>
14094                  <td>{{ classes }}</td>
14095                </tr>
14096                <tr>
14097                  <td>Variables</td>
14098                  <td>{{ variables }}</td>
14099                </tr>
14100                <tr>
14101                  <td>Imports</td>
14102                  <td>{{ imports }}</td>
14103                </tr>
14104              </tbody>
14105            </table>
14106          </div>
14107
14108          <div class="metrics-table-wrap">
14109            <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
14110            <table class="metrics-table">
14111              <thead>
14112                <tr>
14113                  <th>Metric</th>
14114                  <th>Change</th>
14115                </tr>
14116              </thead>
14117              <tbody>
14118                <tr>
14119                  <td>Lines added</td>
14120                  <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14121                </tr>
14122                <tr>
14123                  <td>Lines removed</td>
14124                  <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">&minus;{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14125                </tr>
14126                <tr>
14127                  <td>Lines modified (net)</td>
14128                  <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
14129                </tr>
14130                <tr>
14131                  <td>Lines unmodified</td>
14132                  <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
14133                </tr>
14134              </tbody>
14135            </table>
14136          </div>
14137        </div>
14138
14139      </div>
14140
14141      <div class="path-list">
14142        <div class="path-item">
14143          <div class="path-item-label">Project path</div>
14144          <code>{{ project_path }}</code>
14145        </div>
14146        <div class="path-item">
14147          <div class="path-item-label">Git branch</div>
14148          {% if let Some(branch) = git_branch %}
14149          <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
14150          {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
14151          {% else %}
14152          <code style="color:var(--muted)">—</code>
14153          {% endif %}
14154        </div>
14155        <div class="path-item">
14156          <div class="path-item-label">Output folder</div>
14157          <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
14158        </div>
14159        <div class="path-item">
14160          <div class="path-item-label">Run ID</div>
14161          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
14162            <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
14163            <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
14164          </div>
14165        </div>
14166      </div>
14167    </section>
14168
14169    <div id="r-tt" aria-hidden="true"></div>
14170
14171    <div class="section-pair">
14172    <section class="panel">
14173        <div class="toolbar-row">
14174          <div>
14175            <h2>Language breakdown</h2>
14176            <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
14177          </div>
14178        </div>
14179        <div id="result-lang-charts" style="margin:0 0 8px;"></div>
14180    </section>
14181
14182    <section class="panel r-chart-section">
14183      <div class="toolbar-row" style="margin-bottom:16px;">
14184        <div>
14185          <h2>Visualizations</h2>
14186          <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
14187        </div>
14188      </div>
14189
14190      <div class="r-viz-grid">
14191        <div class="r-viz-card">
14192          <p class="r-viz-card-title">Language Composition</p>
14193          <div class="r-chart-tab-bar">
14194            <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
14195            <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
14196          </div>
14197          <div class="r-chart-container" id="r-composition-chart"></div>
14198        </div>
14199        <div class="r-viz-card">
14200          <p class="r-viz-card-title">Files vs Code Lines</p>
14201          <div class="r-chart-container" id="r-scatter-chart"></div>
14202        </div>
14203        {% if has_semantic_data %}
14204        <div class="r-viz-card">
14205          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14206            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
14207            <select class="r-chart-select" id="r-semantic-metric">
14208              <option value="functions">Functions</option>
14209              <option value="classes">Classes</option>
14210              <option value="variables">Variables</option>
14211              <option value="imports">Imports</option>
14212            </select>
14213          </div>
14214          <div class="r-chart-container" id="r-semantic-chart"></div>
14215        </div>
14216        {% endif %}
14217        {% if has_submodule_data %}
14218        <div class="r-viz-card">
14219          <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
14220            <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Submodule Breakdown</p>
14221            <select class="r-chart-select" id="r-sub-metric">
14222              <option value="code">Code Lines</option>
14223              <option value="comment">Comments</option>
14224              <option value="blank">Blank Lines</option>
14225              <option value="physical">Physical Lines</option>
14226              <option value="files">Files</option>
14227            </select>
14228            <select class="r-chart-select" id="r-sub-sort">
14229              <option value="desc">Value ↓</option>
14230              <option value="asc">Value ↑</option>
14231              <option value="name">Name A→Z</option>
14232            </select>
14233          </div>
14234          <div class="r-chart-container" id="r-submodule-chart"></div>
14235        </div>
14236        {% endif %}
14237      </div>
14238
14239    </section>
14240    </div>
14241
14242  </div>
14243
14244  <script nonce="{{ csp_nonce }}">
14245    (function () {
14246      var body = document.body;
14247      var themeToggle = document.getElementById('theme-toggle');
14248      var storageKey = 'oxide-sloc-theme';
14249
14250      function applyTheme(theme) {
14251        body.classList.toggle('dark-theme', theme === 'dark');
14252      }
14253
14254      function loadSavedTheme() {
14255        try {
14256          var saved = localStorage.getItem(storageKey);
14257          if (saved === 'dark' || saved === 'light') {
14258            applyTheme(saved);
14259          }
14260        } catch (e) {}
14261      }
14262
14263      if (themeToggle) {
14264        themeToggle.addEventListener('click', function () {
14265          var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
14266          applyTheme(nextTheme);
14267          try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
14268        });
14269      }
14270
14271      Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
14272        button.addEventListener('click', function () {
14273          var value = button.getAttribute('data-copy-value') || '';
14274          if (!value) return;
14275          if (navigator.clipboard && navigator.clipboard.writeText) {
14276            navigator.clipboard.writeText(value).catch(function () {});
14277          }
14278        });
14279      });
14280
14281      Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
14282        btn.addEventListener('click', function () {
14283          var folder = btn.getAttribute('data-folder') || '';
14284          if (!folder) return;
14285          fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
14286        });
14287      });
14288
14289      loadSavedTheme();
14290
14291      // ── Compact number formatting for stat chips ──────────────────────────
14292      (function(){
14293        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();}
14294        Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
14295          var raw=parseInt(chip.getAttribute('data-raw'),10);
14296          if(isNaN(raw))return;
14297          var valEl=chip.querySelector('.stat-chip-val');
14298          if(valEl)valEl.textContent=fmt(raw);
14299          var exactEl=chip.querySelector('.stat-chip-exact');
14300          if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
14301        });
14302      })();
14303
14304      // ── Shared tooltip for all result-page charts ─────────────────────────
14305      var rTT=(function(){
14306        var el=document.getElementById('r-tt');
14307        if(!el)return{s:function(){},h:function(){},m:function(){}};
14308        function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
14309        function hide(){el.style.display='none';}
14310        function move(e){
14311          var x=e.clientX+16,y=e.clientY-12;
14312          var r=el.getBoundingClientRect();
14313          if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
14314          if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
14315          el.style.left=x+'px';el.style.top=y+'px';
14316        }
14317        return{s:show,h:hide,m:move};
14318      })();
14319      window.rTT=rTT;
14320
14321      // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
14322      (function(){
14323        function escH(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14324        document.addEventListener('mouseover',function(e){
14325          var t=e.target;
14326          while(t&&t.getAttribute){
14327            var l=t.getAttribute('data-ttl');
14328            if(l!==null){
14329              var v=t.getAttribute('data-ttv')||'';
14330              rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
14331              return;
14332            }
14333            t=t.parentNode;
14334          }
14335        });
14336        document.addEventListener('mouseout',function(e){
14337          var t=e.target;
14338          while(t&&t.getAttribute){
14339            if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
14340            t=t.parentNode;
14341          }
14342        });
14343        document.addEventListener('mousemove',function(e){
14344          var el=document.getElementById('r-tt');
14345          if(el&&el.style.display!=='none')rTT.m(e);
14346        });
14347      })();
14348
14349      // ── Language overview charts ───────────────────────────────────────────
14350      (function(){
14351        var D={{ lang_chart_json|safe }};
14352        if(!D||!D.length)return;
14353        var el=document.getElementById('result-lang-charts');
14354        if(!el)return;
14355        var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14356        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
14357        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14358        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();}
14359        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14360        function px(n){return Math.round(n);}
14361        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+'"';}
14362        var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
14363
14364        // Donut chart — fixed 240×240 viewBox, legend to the right inside the SVG
14365        var cx=100,cy=110,Ro=88,Ri=48;
14366        var legX=204,DW=360,DH=220;
14367        var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14368        if(D.length===1){
14369          var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
14370          ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
14371        } else {
14372          var ang=-Math.PI/2;
14373          D.forEach(function(d,i){
14374            var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
14375            var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
14376            var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
14377            var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
14378            var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
14379            var pct=Math.round(d.code/tot*100);
14380            ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+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="'+(COLS[i%COLS.length])+'" stroke="white" stroke-width="2"/>';
14381            ang+=sw;
14382          });
14383        }
14384        ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
14385        ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
14386        var legRows=Math.min(D.length,8);
14387        var legYStart=Math.round((DH-legRows*22)/2);
14388        D.forEach(function(d,i){
14389          if(i>=8)return;
14390          var ly=legYStart+i*22;
14391          ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
14392          ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14393        });
14394        ds+='</svg>';
14395
14396        // Horizontal stacked-bar chart — fills container width
14397        var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
14398        var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
14399        var bs='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14400        D.forEach(function(d,i){
14401          var y=6+i*rHb,x=LW;
14402          var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
14403          bs+='<text x="'+(LW-6)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
14404          if(cW>0.5)bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'" rx="0"/>';x+=cW;
14405          if(cmW>0.5)bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'" rx="0"/>';x+=cmW;
14406          if(blW>0.5)bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'" rx="0"/>';
14407          bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(d.code+d.comments+d.blanks)+'</text>';
14408        });
14409        var ly=SH-14;
14410        bs+='<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"/><text x="'+(LW+13)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
14411        bs+='<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"/><text x="'+(LW+67)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
14412        bs+='<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"/><text x="'+(LW+165)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
14413        bs+='</svg>';
14414        el.innerHTML='<div class="r-lang-overview">'+
14415          '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
14416          '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
14417        '</div>';
14418      })();
14419
14420      // ── Extended charts (composition, scatter, semantic, submodule) ─────────
14421      (function(){
14422        var LANG_D={{ lang_chart_json|safe }};
14423        var SCAT_D={{ scatter_chart_json|safe }};
14424        var SEM_D={{ semantic_chart_json|safe }};
14425        var SUB_D={{ submodule_chart_json|safe }};
14426        var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
14427        var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
14428        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();}
14429        function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
14430        function px(n){return Math.round(n);}
14431        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+'"';}
14432
14433        // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
14434        function renderComposition(mode){
14435          var el=document.getElementById('r-composition-chart');
14436          if(!el||!LANG_D||!LANG_D.length)return;
14437          var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
14438          var LW=110,SH=224;
14439          var svgW=Math.max(320,el.offsetWidth||480);
14440          var BW=Math.max(120,svgW-LW-80);
14441          var legendH=24,topPad=4;
14442          var n=LANG_D.length||1;
14443          var rowTotal=Math.floor((SH-legendH-topPad)/n);
14444          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
14445          var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14446          if(mode==='pct'){
14447            LANG_D.forEach(function(d,i){
14448              var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
14449              var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
14450              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14451              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>';
14452              if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
14453              if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
14454              if(blW>0.5)s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
14455              var pct=Math.round((d.code||0)/tot2*100);
14456              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+pct+'%</text>';
14457            });
14458          } else {
14459            var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
14460            LANG_D.forEach(function(d,i){
14461              var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
14462              var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
14463              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>';
14464              if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
14465              if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
14466              if(blW>0.5)s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
14467              s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+fmt((d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
14468            });
14469          }
14470          var ly=SH-legendH+4;
14471          s+='<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"/><text x="'+(LW+13)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>';
14472          s+='<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"/><text x="'+(LW+66)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>';
14473          s+='<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"/><text x="'+(LW+165)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>';
14474          s+='</svg>';
14475          el.innerHTML=s;
14476        }
14477        renderComposition('abs');
14478        Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
14479          btn.addEventListener('click',function(){
14480            Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
14481            btn.classList.add('active');
14482            renderComposition(btn.getAttribute('data-rcomp'));
14483          });
14484        });
14485
14486        // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
14487        (function(){
14488          var el=document.getElementById('r-scatter-chart');
14489          if(!el||!SCAT_D||!SCAT_D.length)return;
14490          var H=224,PL=52,PB=36,PT=12,PR=14;
14491          var W=Math.max(320,el.offsetWidth||480);
14492          var cW=W-PL-PR,cH=H-PT-PB;
14493          var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14494          var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14495          var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14496          var s='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14497          [0,0.25,0.5,0.75,1].forEach(function(t){
14498            var y=PT+cH*(1-t);
14499            s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14500            if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';
14501          });
14502          [0,0.25,0.5,0.75,1].forEach(function(t){
14503            var x=PL+cW*t;
14504            s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
14505            if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';
14506          });
14507          SCAT_D.forEach(function(d,i){
14508            var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;
14509            var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
14510            s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
14511            if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';
14512          });
14513          s+='<text x="'+(PL+cW/2)+'" y="'+(H-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7">Files</text>';
14514          s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
14515          s+='</svg>';
14516          el.innerHTML=s;
14517        })();
14518
14519        // ── Semantic: horizontal bar chart (one bar per language) ─────────────
14520        // Horizontal layout avoids the portrait-aspect scaling bug that plagued
14521        // the old vertical column layout on wide containers.
14522        function renderSemantic(key){
14523          var el=document.getElementById('r-semantic-chart');
14524          if(!el||!SEM_D||!SEM_D.length)return;
14525          var LW=112,SH=224;
14526          var svgW=Math.max(320,el.offsetWidth||480);
14527          var BW=Math.max(120,svgW-LW-80);
14528          var topPad=4,botPad=14;
14529          var n2=SEM_D.length||1;
14530          var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
14531          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
14532          var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
14533          var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14534          SEM_D.forEach(function(d,i){
14535            var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
14536            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>';
14537            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"/>';
14538            s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(v)+'</text>';
14539          });
14540          s+='</svg>';
14541          el.innerHTML=s;
14542        }
14543        var semSel=document.getElementById('r-semantic-metric');
14544        if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
14545
14546        // ── Submodule: horizontal bar chart ────────────────────────────────────
14547        function renderSubmodule(key,sort){
14548          var el=document.getElementById('r-submodule-chart');
14549          if(!el||!SUB_D||!SUB_D.length)return;
14550          var data=SUB_D.slice();
14551          if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
14552          else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
14553          else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
14554          var LW=128,SH=224;
14555          var svgW=Math.max(320,el.offsetWidth||480);
14556          var BW=Math.max(120,svgW-LW-80);
14557          var topPad3=4,botPad3=14;
14558          var n3=data.length||1;
14559          var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
14560          var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
14561          var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
14562          var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14563          data.forEach(function(d,i){
14564            var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-bH)/2);
14565            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.name||d.path||'?')+'</text>';
14566            if(bw>0.5)s+='<rect'+tt(d.name||'?',fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
14567            s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(v)+'</text>';
14568          });
14569          s+='</svg>';
14570          el.innerHTML=s;
14571        }
14572        var subSel=document.getElementById('r-sub-metric');
14573        var sortSel=document.getElementById('r-sub-sort');
14574        if(subSel){
14575          renderSubmodule('code','desc');
14576          subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
14577          if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
14578        }
14579
14580        // Re-render all SVG charts when the window is resized so bars fill the card.
14581        var _rResizeTimer;
14582        window.addEventListener('resize',function(){
14583          clearTimeout(_rResizeTimer);
14584          _rResizeTimer=setTimeout(function(){
14585            var rcompBtn=document.querySelector('[data-rcomp].active');
14586            renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
14587            (function(){
14588              var scEl=document.getElementById('r-scatter-chart');
14589              if(!scEl||!SCAT_D||!SCAT_D.length)return;
14590              var H=224,PL=52,PB=36,PT=12,PR=14;
14591              var W=Math.max(320,scEl.offsetWidth||480);
14592              var cW=W-PL-PR,cH=H-PT-PB;
14593              var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
14594              var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
14595              var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
14596              var s='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
14597              [0,0.25,0.5,0.75,1].forEach(function(t){var y=PT+cH*(1-t);s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxC*t))+'</text>';});
14598              [0,0.25,0.5,0.75,1].forEach(function(t){var x=PL+cW*t;s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.65">'+fmt(Math.round(maxF*t))+'</text>';});
14599              SCAT_D.forEach(function(d,i){var cx2=PL+d.files/maxF*cW,cy2=PT+cH-d.code/maxC*cH;var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';if(r>6)s+='<text x="'+px(cx2)+'" y="'+(px(cy2)-px(r)-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.9" style="pointer-events:none;">'+esc(d.lang)+'</text>';});
14600              s+='<text x="'+(PL+cW/2)+'" y="'+(H-3)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7">Files</text>';
14601              s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" fill="currentColor" opacity="0.7" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
14602              s+='</svg>';scEl.innerHTML=s;
14603            })();
14604            if(semSel)renderSemantic(semSel.value||'functions');
14605            if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
14606          },120);
14607        });
14608      })();
14609
14610      (function randomizeWatermarks() {
14611        var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
14612        if (!wms.length) return;
14613        var placed = [];
14614        function tooClose(top, left) {
14615          for (var i = 0; i < placed.length; i++) {
14616            var dt = Math.abs(placed[i][0] - top);
14617            var dl = Math.abs(placed[i][1] - left);
14618            if (dt < 20 && dl < 18) return true;
14619          }
14620          return false;
14621        }
14622        function pick(leftBand) {
14623          for (var attempt = 0; attempt < 50; attempt++) {
14624            var top = Math.random() * 85 + 5;
14625            var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14626            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
14627          }
14628          var top = Math.random() * 85 + 5;
14629          var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
14630          placed.push([top, left]);
14631          return [top, left];
14632        }
14633        var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
14634        var half = Math.floor(wms.length / 2);
14635        wms.forEach(function (img, i) {
14636          var pos = pick(i < half);
14637          var size = Math.floor(Math.random() * 100 + 160);
14638          var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
14639          var op = (Math.random() * 0.06 + 0.07).toFixed(2);
14640          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;
14641        });
14642      })();
14643
14644      (function spawnCodeParticles() {
14645        var container = document.getElementById('code-particles');
14646        if (!container) return;
14647        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
14648        for (var i = 0; i < 38; i++) {
14649          (function(idx) {
14650            var el = document.createElement('span');
14651            el.className = 'code-particle';
14652            el.textContent = snippets[idx % snippets.length];
14653            var left = Math.random() * 94 + 2;
14654            var top = Math.random() * 88 + 6;
14655            var dur = (Math.random() * 10 + 9).toFixed(1);
14656            var delay = (Math.random() * 18).toFixed(1);
14657            var rot = (Math.random() * 26 - 13).toFixed(1);
14658            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
14659            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';
14660            container.appendChild(el);
14661          })(i);
14662        }
14663      })();
14664
14665      {% if pdf_generating %}
14666      // Poll for PDF readiness and swap the disabled button to a live link once done.
14667      (function() {
14668        var openBtn = document.getElementById('pdf-open-btn');
14669        var dlBtn = document.getElementById('pdf-download-btn');
14670        function checkPdf() {
14671          fetch('/api/runs/{{ run_id }}/pdf-status')
14672            .then(function(r) { return r.json(); })
14673            .then(function(d) {
14674              if (d.ready) {
14675                if (openBtn) {
14676                  var a = document.createElement('a');
14677                  a.className = 'button';
14678                  a.id = 'pdf-open-btn';
14679                  a.href = '/runs/pdf/{{ run_id }}';
14680                  a.target = '_blank';
14681                  a.rel = 'noopener';
14682                  a.textContent = 'Open PDF';
14683                  openBtn.replaceWith(a);
14684                }
14685                if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
14686              } else {
14687                setTimeout(checkPdf, 3000);
14688              }
14689            })
14690            .catch(function() { setTimeout(checkPdf, 5000); });
14691        }
14692        setTimeout(checkPdf, 3000);
14693      })();
14694      {% endif %}
14695
14696    })();
14697  </script>
14698  <script nonce="{{ csp_nonce }}">
14699  (function(){
14700    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'}];
14701    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);});}
14702    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
14703    function init(){
14704      var btn=document.getElementById('settings-btn');if(!btn)return;
14705      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14706      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
14707      document.body.appendChild(m);
14708      var g=document.getElementById('scheme-grid');
14709      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);});
14710      var cl=document.getElementById('settings-close');
14711      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
14712      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');});
14713      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
14714      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
14715    }
14716    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
14717  }());
14718  </script>
14719  <footer class="site-footer">
14720    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
14721    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14722    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14723    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14724    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
14725  </footer>
14726  {% if confluence_configured %}
14727  <script nonce="{{ csp_nonce }}">
14728  (function() {
14729    var postBtn = document.getElementById('postConfluenceBtn');
14730    var copyBtn = document.getElementById('copyWikiBtn');
14731    var modal   = document.getElementById('confluenceModal');
14732    if (!postBtn || !modal) return;
14733
14734    postBtn.addEventListener('click', function() {
14735      document.getElementById('confStatus').style.display = 'none';
14736      modal.style.display = 'flex';
14737    });
14738    document.getElementById('confCancelBtn').addEventListener('click', function() {
14739      modal.style.display = 'none';
14740    });
14741    modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
14742
14743    document.getElementById('confSubmitBtn').addEventListener('click', async function() {
14744      var btn = this;
14745      btn.disabled = true;
14746      var status = document.getElementById('confStatus');
14747      status.style.display = 'block';
14748      status.style.background = '#dbeafe';
14749      status.style.color = '#1e40af';
14750      status.textContent = 'Posting to Confluence…';
14751      var resp = await fetch('/api/confluence/post', {
14752        method: 'POST',
14753        headers: { 'Content-Type': 'application/json' },
14754        body: JSON.stringify({
14755          run_id: '{{ run_id }}',
14756          page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
14757          report_url: document.getElementById('confReportUrl').value.trim() || null
14758        })
14759      });
14760      var data = await resp.json();
14761      if (data.ok) {
14762        status.style.background = '#dcfce7'; status.style.color = '#166534';
14763        status.textContent = 'Posted! Page ID: ' + data.page_id;
14764      } else {
14765        status.style.background = '#fee2e2'; status.style.color = '#991b1b';
14766        status.textContent = 'Error: ' + (data.error || 'Unknown error');
14767      }
14768      btn.disabled = false;
14769    });
14770
14771    if (copyBtn) {
14772      copyBtn.addEventListener('click', async function() {
14773        var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
14774        if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
14775        var text = await resp.text();
14776        try {
14777          await navigator.clipboard.writeText(text);
14778          var orig = copyBtn.textContent;
14779          copyBtn.textContent = 'Copied!';
14780          setTimeout(function() { copyBtn.textContent = orig; }, 2000);
14781        } catch(e) {
14782          alert('Clipboard write failed — check browser permissions.');
14783        }
14784      });
14785    }
14786  })();
14787  </script>
14788  {% endif %}
14789  {% if let Some(banner) = report_header_footer %}
14790  <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
14791  {% endif %}
14792</body>
14793</html>
14794"##,
14795    ext = "html"
14796)]
14797// Template structs need many bool fields to pass Askama rendering flags.
14798#[allow(clippy::struct_excessive_bools)]
14799struct ResultTemplate {
14800    version: &'static str,
14801    report_title: String,
14802    project_path: String,
14803    output_dir: String,
14804    run_id: String,
14805    files_analyzed: u64,
14806    files_skipped: u64,
14807    physical_lines: u64,
14808    code_lines: u64,
14809    comment_lines: u64,
14810    blank_lines: u64,
14811    mixed_lines: u64,
14812    functions: u64,
14813    classes: u64,
14814    variables: u64,
14815    imports: u64,
14816    html_url: Option<String>,
14817    pdf_url: Option<String>,
14818    json_url: Option<String>,
14819    html_download_url: Option<String>,
14820    pdf_download_url: Option<String>,
14821    json_download_url: Option<String>,
14822    html_path: Option<String>,
14823    pdf_path: Option<String>,
14824    json_path: Option<String>,
14825    prev_run_id: Option<String>,
14826    prev_run_timestamp: Option<String>,
14827    prev_run_code_lines: Option<u64>,
14828    // Previous scan summary columns (pre-formatted; "—" when no prior scan)
14829    prev_fa_str: String,
14830    prev_fs_str: String,
14831    prev_pl_str: String,
14832    prev_cl_str: String,
14833    prev_cml_str: String,
14834    prev_bl_str: String,
14835    // Signed change column for main metrics
14836    delta_fa_str: String,
14837    delta_fa_class: String,
14838    delta_fs_str: String,
14839    delta_fs_class: String,
14840    delta_pl_str: String,
14841    delta_pl_class: String,
14842    delta_cl_str: String,
14843    delta_cl_class: String,
14844    delta_cml_str: String,
14845    delta_cml_class: String,
14846    delta_bl_str: String,
14847    delta_bl_class: String,
14848    // delta vs previous scan
14849    delta_lines_added: Option<i64>,
14850    delta_lines_removed: Option<i64>,
14851    delta_lines_net_str: String,
14852    delta_lines_net_class: String,
14853    delta_files_added: Option<usize>,
14854    delta_files_removed: Option<usize>,
14855    delta_files_modified: Option<usize>,
14856    delta_files_unchanged: Option<usize>,
14857    delta_unmodified_lines: Option<u64>,
14858    // git context
14859    git_branch: Option<String>,
14860    git_commit: Option<String>,
14861    git_author: Option<String>,
14862    // history
14863    prev_scan_count: usize,
14864    current_scan_number: usize,
14865    // submodule breakdown (empty when not requested)
14866    submodule_rows: Vec<SubmoduleRow>,
14867    scan_config_url: String,
14868    lang_chart_json: String,
14869    // Askama reads these via proc-macro expansion; clippy can't trace through it.
14870    #[allow(dead_code)]
14871    scatter_chart_json: String,
14872    #[allow(dead_code)]
14873    semantic_chart_json: String,
14874    #[allow(dead_code)]
14875    submodule_chart_json: String,
14876    #[allow(dead_code)]
14877    has_submodule_data: bool,
14878    #[allow(dead_code)]
14879    has_semantic_data: bool,
14880    pdf_generating: bool,
14881    csp_nonce: String,
14882    /// Whether Confluence integration is configured — shows Post button when true.
14883    confluence_configured: bool,
14884    /// Header/footer identification banner, mirrored from the HTML/PDF report.
14885    report_header_footer: Option<String>,
14886}
14887
14888#[derive(Template)]
14889#[template(
14890    source = r##"
14891<!doctype html>
14892<html lang="en">
14893<head>
14894  <meta charset="utf-8">
14895  <meta name="viewport" content="width=device-width, initial-scale=1">
14896  <title>OxideSLOC | Analyzing…</title>
14897  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14898  <style nonce="{{ csp_nonce }}">
14899    :root {
14900      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
14901      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14902      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
14903      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14904    }
14905    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
14906    *{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);}
14907    .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);}
14908    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
14909    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
14910    .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));}
14911    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
14912    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
14913    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
14914    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
14915    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
14916    @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; } }
14917    .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;}
14918    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
14919    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
14920    .page-body{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
14921    .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
14922    .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;}
14923    .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
14924    @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
14925    .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
14926    .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
14927    .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;}
14928    .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
14929    .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;}
14930    .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
14931    .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
14932    .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
14933    .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;}
14934    @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
14935    .hidden{display:none!important;}
14936    .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;}
14937    .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;}
14938    .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
14939    .err-panel p{margin:0;font-size:13px;color:var(--muted);}
14940    .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
14941    .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);}
14942    .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
14943    .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;}
14944    .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
14945    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14946    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
14947    @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
14948    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
14949    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
14950    @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));}}
14951    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
14952    .site-footer a{color:var(--muted);}
14953    .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;}
14954    .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
14955    body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
14956    body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
14957  </style>
14958</head>
14959<body>
14960  <div class="background-watermarks" aria-hidden="true">
14961    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14962    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14963    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14964    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14965    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14966    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14967  </div>
14968  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14969  <nav class="top-nav">
14970    <div class="top-nav-inner">
14971      <a href="/" class="brand">
14972        <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
14973        <div class="brand-copy">
14974          <h1 class="brand-title">OxideSLOC</h1>
14975          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
14976        </div>
14977      </a>
14978      <div class="nav-right">
14979        <a class="nav-pill" href="/">Home</a>
14980        <div class="nav-dropdown">
14981          <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>
14982          <div class="nav-dropdown-menu">
14983            <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>
14984          </div>
14985        </div>
14986        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14987        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
14988        <div class="nav-dropdown">
14989          <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>
14990          <div class="nav-dropdown-menu">
14991            <a href="/webhook-setup"><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>
14992          </div>
14993        </div>
14994        <div class="server-status-wrap">
14995          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
14996          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
14997        </div>
14998        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14999          <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>
15000        </button>
15001        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15002          <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>
15003          <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>
15004        </button>
15005      </div>
15006    </div>
15007  </nav>
15008  <div class="page-body">
15009    <div class="wait-panel">
15010      <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
15011      <h2 class="wait-title">Analyzing your project…</h2>
15012      <p class="wait-sub">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</p>
15013      <div class="path-block">{{ project_path }}</div>
15014      <div class="metrics-row">
15015        <div class="metric-card">
15016          <div class="metric-label">Elapsed</div>
15017          <div class="metric-value" id="elapsed">0s</div>
15018        </div>
15019        <div class="metric-card">
15020          <div class="metric-label">Phase</div>
15021          <div class="metric-value" id="phase">Starting</div>
15022        </div>
15023      </div>
15024      <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
15025      <div class="warn-slow hidden" id="warn-slow">
15026        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.
15027      </div>
15028      <div class="err-panel hidden" id="err-panel">
15029        <strong>Analysis failed</strong>
15030        <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
15031      </div>
15032      <div class="actions hidden" id="actions">
15033        <a href="/scan" class="btn-primary">Try Again</a>
15034        <a href="/view-reports" class="btn-outline">View Reports</a>
15035      </div>
15036    </div>
15037  </div>
15038  <script nonce="{{ csp_nonce }}">
15039    (function() {
15040      var WAIT_ID = {{ wait_id_json|safe }};
15041      var startTime = Date.now();
15042      var pollInterval = 1500;
15043      var retries = 0;
15044      var maxRetries = 5;
15045      var warnShown = false;
15046
15047      function elapsed() {
15048        return Math.floor((Date.now() - startTime) / 1000);
15049      }
15050
15051      function updateElapsed() {
15052        var s = elapsed();
15053        document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
15054      }
15055
15056      function setPhase(txt) {
15057        document.getElementById('phase').textContent = txt;
15058      }
15059
15060      var elapsedTimer = setInterval(updateElapsed, 1000);
15061
15062      function poll() {
15063        fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
15064          .then(function(r) {
15065            if (!r.ok) throw new Error('HTTP ' + r.status);
15066            return r.json();
15067          })
15068          .then(function(data) {
15069            retries = 0;
15070            if (data.state === 'complete') {
15071              clearInterval(elapsedTimer);
15072              setPhase('Done');
15073              window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
15074            } else if (data.state === 'failed') {
15075              clearInterval(elapsedTimer);
15076              setPhase('Failed');
15077              document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
15078              document.getElementById('err-panel').classList.remove('hidden');
15079              document.getElementById('actions').classList.remove('hidden');
15080            } else {
15081              // still running
15082              var s = elapsed();
15083              if (s > 90 && !warnShown) {
15084                warnShown = true;
15085                document.getElementById('warn-slow').classList.remove('hidden');
15086              }
15087              setPhase(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
15088              setTimeout(poll, pollInterval);
15089            }
15090          })
15091          .catch(function(err) {
15092            retries++;
15093            if (retries >= maxRetries) {
15094              clearInterval(elapsedTimer);
15095              document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
15096              document.getElementById('err-panel').classList.remove('hidden');
15097              document.getElementById('actions').classList.remove('hidden');
15098            } else {
15099              // exponential back-off capped at 8s
15100              setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
15101            }
15102          });
15103      }
15104
15105      setTimeout(poll, pollInterval);
15106    })();
15107  </script>
15108  <footer class="site-footer">
15109    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
15110    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15111    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15112    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15113    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15114  </footer>
15115  <script nonce="{{ csp_nonce }}">
15116    (function(){
15117      var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
15118      if(s==="dark")b.classList.add("dark-theme");
15119      var tt=document.getElementById("theme-toggle");
15120      if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
15121    })();
15122    (function spawnCodeParticles(){
15123      var c=document.getElementById('code-particles');if(!c)return;
15124      var sn=['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n=0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main()','sloc_core','render_html','2,163 code'];
15125      for(var i=0;i<32;i++){(function(idx){
15126        var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
15127        var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
15128        var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
15129        var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
15130        el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
15131        el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
15132        c.appendChild(el);
15133      })(i);}
15134    })();
15135    (function randomizeWatermarks(){
15136      var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15137      var placed=[];
15138      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;}
15139      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];}
15140      var half=Math.floor(wms.length/2);
15141      wms.forEach(function(img,i){
15142        var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
15143        var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
15144        var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
15145        img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
15146        img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
15147        img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
15148      });
15149    })();
15150  </script>
15151  <script nonce="{{ csp_nonce }}">
15152  (function(){
15153    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'}];
15154    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);});}
15155    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15156    function init(){
15157      var btn=document.getElementById('settings-btn');if(!btn)return;
15158      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15159      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
15160      document.body.appendChild(m);
15161      var g=document.getElementById('scheme-grid');
15162      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);});
15163      var cl=document.getElementById('settings-close');
15164      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
15165      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');});
15166      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15167      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15168    }
15169    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15170  }());
15171  </script>
15172</body>
15173</html>
15174"##,
15175    ext = "html"
15176)]
15177struct ScanWaitTemplate {
15178    version: &'static str,
15179    wait_id_json: String,
15180    project_path: String,
15181    csp_nonce: String,
15182}
15183
15184#[derive(Template)]
15185#[template(
15186    source = r##"
15187<!doctype html>
15188<html lang="en">
15189<head>
15190  <meta charset="utf-8">
15191  <meta name="viewport" content="width=device-width, initial-scale=1">
15192  <title>OxideSLOC | Error</title>
15193  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15194  <style nonce="{{ csp_nonce }}">
15195    :root {
15196      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15197      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15198      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15199      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15200    }
15201    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15202    *{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);}
15203    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15204    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15205    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15206    .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);}
15207    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15208    .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));}
15209    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15210    .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;}
15211    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15212    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15213    @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; } }
15214    .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;}
15215    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15216    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15217    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15218    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15219    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15220    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
15221    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15222    .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);}
15223    .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;}
15224    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15225    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15226    .settings-modal-body{padding:14px 16px 16px;}
15227    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15228    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15229    .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;}
15230    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15231    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15232    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15233    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15234    .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;}
15235    .tz-select:focus{border-color:var(--oxide);}
15236    .page{max-width:1720px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15237    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15238    h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15239    .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;}
15240    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15241    .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);}
15242    .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;}
15243    .btn-secondary:hover{background:var(--line);}
15244    .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;}
15245    .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;}
15246    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
15247    @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));}}
15248    .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;}
15249  </style>
15250</head>
15251<body>
15252  <div class="background-watermarks" aria-hidden="true">
15253    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15254    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15255    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15256    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15257    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15258    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15259  </div>
15260  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15261  <div class="top-nav">
15262    <div class="top-nav-inner">
15263      <a class="brand" href="/">
15264        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15265        <div class="brand-copy">
15266          <div class="brand-title">OxideSLOC</div>
15267          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15268        </div>
15269      </a>
15270      <div class="nav-right">
15271        <a class="nav-pill" href="/">Home</a>
15272        <div class="nav-dropdown">
15273          <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>
15274          <div class="nav-dropdown-menu">
15275            <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>
15276          </div>
15277        </div>
15278        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15279        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15280        <div class="nav-dropdown">
15281          <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>
15282          <div class="nav-dropdown-menu">
15283            <a href="/webhook-setup"><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>
15284          </div>
15285        </div>
15286        <div class="server-status-wrap">
15287          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15288          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
15289        </div>
15290        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15291          <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>
15292        </button>
15293        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15294          <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>
15295          <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>
15296        </button>
15297      </div>
15298    </div>
15299  </div>
15300
15301  <div class="page">
15302    <div class="panel">
15303      <h1>Error</h1>
15304      <div class="error-box">{{ message }}</div>
15305      <div class="actions">
15306        <a class="btn-primary" href="/scan">Back to setup</a>
15307        {% if let Some(report_url) = last_report_url %}
15308        <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
15309        {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
15310        {% else %}
15311        <a class="btn-secondary" href="/view-reports">View Reports</a>
15312        {% endif %}
15313      </div>
15314    </div>
15315  </div>
15316  <script nonce="{{ csp_nonce }}">
15317    (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");});})();
15318    (function spawnCodeParticles() {
15319      var container = document.getElementById('code-particles');
15320      if (!container) return;
15321      var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
15322      for (var i = 0; i < 38; i++) {
15323        (function(idx) {
15324          var el = document.createElement('span');
15325          el.className = 'code-particle';
15326          el.textContent = snippets[idx % snippets.length];
15327          var left = Math.random() * 94 + 2;
15328          var top = Math.random() * 88 + 6;
15329          var dur = (Math.random() * 10 + 9).toFixed(1);
15330          var delay = (Math.random() * 18).toFixed(1);
15331          var rot = (Math.random() * 26 - 13).toFixed(1);
15332          var op = (Math.random() * 0.09 + 0.06).toFixed(3);
15333          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';
15334          container.appendChild(el);
15335        })(i);
15336      }
15337    })();
15338    (function randomizeWatermarks() {
15339      var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15340      var placed = [];
15341      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; }
15342      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]; }
15343      var half = Math.floor(wms.length/2);
15344      wms.forEach(function(img, i) {
15345        var pos = pick(i < half);
15346        var w = Math.floor(Math.random()*60+80);
15347        var rot = (Math.random()*40-20).toFixed(1);
15348        var op = (Math.random()*0.08+0.05).toFixed(2);
15349        var animDur = (Math.random()*6+5).toFixed(1);
15350        var animDelay = (Math.random()*10).toFixed(1);
15351        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';
15352      });
15353    })();
15354  </script>
15355  <script nonce="{{ csp_nonce }}">
15356  (function(){
15357    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'}];
15358    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);});}
15359    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15360    function init(){
15361      var btn=document.getElementById('settings-btn');if(!btn)return;
15362      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15363      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
15364      document.body.appendChild(m);
15365      var g=document.getElementById('scheme-grid');
15366      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);});
15367      var cl=document.getElementById('settings-close');
15368      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
15369      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');});
15370      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15371      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15372    }
15373    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15374  }());
15375  </script>
15376</body>
15377</html>
15378"##,
15379    ext = "html"
15380)]
15381struct ErrorTemplate {
15382    message: String,
15383    /// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
15384    last_report_url: Option<String>,
15385    /// Label for the secondary action button; defaults to "View last report" when None.
15386    last_report_label: Option<String>,
15387    csp_nonce: String,
15388}
15389
15390// ── RelocateScanTemplate ──────────────────────────────────────────────────────
15391
15392#[derive(Template)]
15393#[template(
15394    source = r##"
15395<!doctype html>
15396<html lang="en">
15397<head>
15398  <meta charset="utf-8">
15399  <meta name="viewport" content="width=device-width, initial-scale=1">
15400  <title>OxideSLOC | Locate Scan Files</title>
15401  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15402  <style nonce="{{ csp_nonce }}">
15403    :root {
15404      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
15405      --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15406      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
15407      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15408    }
15409    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
15410    *{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);}
15411    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15412    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15413    @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
15414    .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);}
15415    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15416    .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));}
15417    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15418    .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;}
15419    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15420    @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
15421    @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;}}
15422    .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;}
15423    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15424    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15425    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15426    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15427    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15428    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
15429    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15430    .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);}
15431    .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;}
15432    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15433    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15434    .settings-modal-body{padding:14px 16px 16px;}
15435    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15436    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15437    .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;}
15438    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15439    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15440    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15441    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15442    .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;}
15443    .tz-select:focus{border-color:var(--oxide);}
15444    .page{max-width:860px;margin:0 auto;padding:28px 24px 40px;position:relative;z-index:1;}
15445    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
15446    h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
15447    .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
15448    .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;}
15449    .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
15450    .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;}
15451    .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;}
15452    .btn-secondary:hover{background:var(--line);}
15453    .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;}
15454    .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;}
15455    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
15456    @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));}}
15457    .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;}
15458    .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
15459    .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
15460    .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
15461    .relocate-row{display:flex;gap:8px;align-items:stretch;}
15462    .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;}
15463    .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
15464    body.dark-theme .relocate-input{background:var(--surface-2);}
15465  </style>
15466</head>
15467<body>
15468  <div class="background-watermarks" aria-hidden="true">
15469    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15470    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15471    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15472    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15473    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15474    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15475  </div>
15476  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15477  <div class="top-nav">
15478    <div class="top-nav-inner">
15479      <a class="brand" href="/">
15480        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
15481        <div class="brand-copy">
15482          <div class="brand-title">OxideSLOC</div>
15483          <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
15484        </div>
15485      </a>
15486      <div class="nav-right">
15487        <a class="nav-pill" href="/">Home</a>
15488        <div class="nav-dropdown">
15489          <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>
15490          <div class="nav-dropdown-menu">
15491            <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>
15492          </div>
15493        </div>
15494        <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
15495        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15496        <div class="nav-dropdown">
15497          <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>
15498          <div class="nav-dropdown-menu">
15499            <a href="/webhook-setup"><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>
15500          </div>
15501        </div>
15502        <div class="server-status-wrap">
15503          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15504          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
15505        </div>
15506        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15507          <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>
15508        </button>
15509        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15510          <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>
15511          <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>
15512        </button>
15513      </div>
15514    </div>
15515  </div>
15516
15517  <div class="page">
15518    <div class="panel">
15519      <h1>Scan Files Moved</h1>
15520      <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
15521      <div class="error-box">{{ message }}</div>
15522      <div class="relocate-section">
15523        <h2>Locate Scan Output</h2>
15524        <p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
15525        <form method="post" action="/relocate-scan">
15526          <input type="hidden" name="run_id" value="{{ run_id }}">
15527          <input type="hidden" name="redirect_url" value="{{ redirect_url }}">
15528          <div class="relocate-row">
15529            <input type="text" id="relocate-folder" name="folder_path"
15530                   value="{{ folder_hint }}"
15531                   placeholder="Path to folder containing scan output..."
15532                   class="relocate-input" autocomplete="off" spellcheck="false">
15533            {% if !server_mode %}
15534            <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse&hellip;</button>
15535            {% endif %}
15536          </div>
15537          <div style="margin-top:12px;">
15538            <button type="submit" class="btn-primary" style="border:none;">Restore Scan</button>
15539          </div>
15540        </form>
15541      </div>
15542      <div class="actions">
15543        <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
15544        <a class="btn-secondary" href="/view-reports">View Reports</a>
15545      </div>
15546    </div>
15547  </div>
15548  <script nonce="{{ csp_nonce }}">
15549    (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");});})();
15550    (function spawnCodeParticles(){var c=document.getElementById('code-particles');if(!c)return;var snips=['scan moved','fn analyze()','result.json','.html .pdf','locate files','restore scan','folder path','result*.json','run_id','compare','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}})();
15551    (function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));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()*60+80),rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2),dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);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 '+dur+'s ease-in-out -'+delay+'s infinite alternate';});})();
15552  </script>
15553  <script nonce="{{ csp_nonce }}">
15554  (function(){
15555    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'}];
15556    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);});}
15557    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
15558    function init(){
15559      var btn=document.getElementById('settings-btn');if(!btn)return;
15560      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15561      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
15562      document.body.appendChild(m);
15563      var g=document.getElementById('scheme-grid');
15564      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);});
15565      var cl=document.getElementById('settings-close');
15566      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
15567      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');});
15568      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
15569      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
15570    }
15571    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
15572  }());
15573  (function(){
15574    var btn=document.getElementById('browse-relocate-btn');
15575    if(!btn)return;
15576    btn.addEventListener('click',function(){
15577      btn.disabled=true;btn.textContent='...';
15578      var inp=document.getElementById('relocate-folder');
15579      var hint=inp?inp.value:'';
15580      fetch('/pick-directory?kind=reports&current='+encodeURIComponent(hint))
15581        .then(function(r){return r.json();})
15582        .then(function(d){
15583          btn.disabled=false;btn.textContent='Browse…';
15584          if(d&&d.selected_path&&inp)inp.value=d.selected_path;
15585        })
15586        .catch(function(){btn.disabled=false;btn.textContent='Browse…';});
15587    });
15588  }());
15589  </script>
15590</body>
15591</html>
15592"##,
15593    ext = "html"
15594)]
15595struct RelocateScanTemplate {
15596    message: String,
15597    run_id: String,
15598    folder_hint: String,
15599    redirect_url: String,
15600    server_mode: bool,
15601    csp_nonce: String,
15602}
15603
15604// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
15605
15606#[derive(Template)]
15607#[template(
15608    source = r##"
15609<!doctype html>
15610<html lang="en">
15611<head>
15612  <meta charset="utf-8">
15613  <meta name="viewport" content="width=device-width, initial-scale=1">
15614  <title>OxideSLOC | View Reports</title>
15615  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
15616  <style nonce="{{ csp_nonce }}">
15617    :root {
15618      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
15619      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
15620      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
15621      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
15622      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
15623    }
15624    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; }
15625    *{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);}
15626    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
15627    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
15628    .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);}
15629    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
15630    .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));}
15631    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
15632    .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;}
15633    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
15634    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
15635    @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; } }
15636    .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;}
15637    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
15638    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
15639    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
15640    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
15641    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
15642    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
15643    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
15644    .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);}
15645    .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;}
15646    .settings-close:hover{color:var(--text);background:var(--surface-2);}
15647    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
15648    .settings-modal-body{padding:14px 16px 16px;}
15649    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
15650    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
15651    .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;}
15652    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
15653    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
15654    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
15655    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
15656    .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;}
15657    .tz-select:focus{border-color:var(--oxide);}
15658    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
15659    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
15660    .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
15661    .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
15662    .panel-meta{font-size:13px;color:var(--muted);}
15663    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
15664    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
15665    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
15666    .per-page-label{font-size:13px;color:var(--muted);}
15667    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;}
15668    .filter-input{min-width:180px;cursor:text;}
15669    .table-wrap{width:100%;overflow-x:auto;}
15670    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
15671    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;}
15672    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
15673    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
15674    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
15675    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
15676    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
15677    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15678    tr:last-child td{border-bottom:none;}
15679    tr:hover td{background:var(--surface-2);}
15680    .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);}
15681    .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);}
15682    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
15683    .metric-num{font-weight:700;color:var(--text);}
15684    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
15685    .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;}
15686    .btn:hover{background:var(--line);}
15687    .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15688    .btn.primary:hover{opacity:.9;}
15689    .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;}
15690    .btn-back:hover{background:var(--line);}
15691    .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;}
15692    .export-btn:hover{background:var(--line);}
15693    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
15694    .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
15695    .no-report{color:var(--muted);font-size:11px;font-style:italic;}
15696    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
15697    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
15698    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
15699    .pagination-info{font-size:13px;color:var(--muted);}
15700    .pagination-btns{display:flex;gap:6px;}
15701    .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;}
15702    .pg-btn:hover:not(:disabled){background:var(--line);}
15703    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
15704    .pg-btn:disabled{opacity:.35;cursor:default;}
15705    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
15706    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
15707    .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
15708    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
15709    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
15710    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
15711    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
15712    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
15713    .stat-chip:hover .stat-chip-tip{opacity:1;}
15714    .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;}
15715    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
15716    .site-footer a{color:var(--muted);}
15717    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
15718    .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%;}
15719    .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
15720    .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;}
15721    body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
15722    .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;}
15723    body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
15724    .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;}
15725    .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;}
15726    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
15727    @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));}}
15728    .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;}
15729    .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;}
15730    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
15731    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
15732    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
15733    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
15734    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
15735    .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;}
15736    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
15737    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
15738    .watched-chip-rm:hover{color:var(--oxide);}
15739    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
15740    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
15741    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
15742    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
15743    .rpt-btn{min-width:58px;justify-content:center;}
15744    .flex-row{display:flex;align-items:center;gap:8px;}
15745    .report-cell{overflow:visible;white-space:normal;}
15746    #history-table col:nth-child(1){width:185px;}
15747    #history-table col:nth-child(2){width:220px;}
15748    #history-table col:nth-child(3){width:100px;}
15749    #history-table col:nth-child(4){width:72px;}
15750    #history-table col:nth-child(5){width:82px;}
15751    #history-table col:nth-child(6){width:82px;}
15752    #history-table col:nth-child(7){width:65px;}
15753    #history-table col:nth-child(8){width:90px;}
15754    #history-table col:nth-child(9){width:85px;}
15755    #history-table col:nth-child(10){width:115px;}
15756    #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
15757    .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
15758    .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
15759    .submod-details summary::-webkit-details-marker{display:none;}
15760.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
15761    .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;}
15762    .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
15763    body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
15764  </style>
15765</head>
15766<body>
15767  <div class="background-watermarks" aria-hidden="true">
15768    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15769    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15770    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15771    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15772    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15773    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15774  </div>
15775  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15776  <div class="top-nav">
15777    <div class="top-nav-inner">
15778      <a class="brand" href="/">
15779        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15780        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
15781      </a>
15782      <div class="nav-right">
15783        <a class="nav-pill" href="/">Home</a>
15784        <div class="nav-dropdown">
15785          <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>
15786          <div class="nav-dropdown-menu">
15787            <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>
15788          </div>
15789        </div>
15790        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15791        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15792        <div class="nav-dropdown">
15793          <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>
15794          <div class="nav-dropdown-menu">
15795            <a href="/webhook-setup"><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>
15796          </div>
15797        </div>
15798        <div class="server-status-wrap">
15799          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
15800          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
15801        </div>
15802        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15803          <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>
15804        </button>
15805        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15806          <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>
15807          <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>
15808        </button>
15809      </div>
15810    </div>
15811  </div>
15812
15813  <div class="page">
15814    {% if let Some(err) = browse_error %}
15815    <div class="toast-error">
15816      <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>
15817      {{ err }}
15818    </div>
15819    {% endif %}
15820    {% if linked_count > 0 %}
15821    <div class="toast-success">
15822      <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>
15823      {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
15824    </div>
15825    {% endif %}
15826    <div class="watched-bar">
15827      <div class="watched-bar-left">
15828        <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>
15829        <span class="watched-label">Watched Folders</span>
15830        <div class="watched-chips">
15831          {% for dir in watched_dirs %}
15832          <span class="watched-chip">
15833            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
15834            <form method="POST" action="/watched-dirs/remove" style="display:contents">
15835              <input type="hidden" name="folder_path" value="{{ dir }}">
15836              <input type="hidden" name="redirect_to" value="/view-reports">
15837              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
15838            </form>
15839          </span>
15840          {% endfor %}
15841          {% if watched_dirs.is_empty() %}
15842          <span class="watched-none">No folders watched — click Choose to add one</span>
15843          {% endif %}
15844        </div>
15845      </div>
15846      <div class="watched-bar-right">
15847        <button type="button" class="btn" id="add-watched-btn">
15848          <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>
15849          Choose
15850        </button>
15851        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
15852          <input type="hidden" name="redirect_to" value="/view-reports">
15853          <button type="submit" class="btn">&#8635; Refresh</button>
15854        </form>
15855      </div>
15856    </div>
15857    {% if total_scans > 0 %}
15858    <div class="summary-strip">
15859      <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>
15860      <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>
15861      <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>
15862      <div class="stat-chip"><div class="stat-chip-tip">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
15863    </div>
15864    {% endif %}
15865
15866    <section class="panel">
15867      <div class="panel-header">
15868        <div>
15869          <h1>View Reports</h1>
15870          <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
15871        </div>
15872        <div class="flex-row">
15873          <button type="button" class="export-btn" id="export-csv-btn">
15874            <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>
15875            Export CSV
15876          </button>
15877          <button type="button" class="export-btn" id="export-xls-btn">
15878            <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>
15879            Export Excel
15880          </button>
15881        </div>
15882      </div>
15883
15884      {% if entries.is_empty() %}
15885      <div class="empty-state">
15886        <strong>No reports with viewable HTML yet</strong>
15887        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.
15888      </div>
15889      {% else %}
15890      <div class="filter-row">
15891        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
15892        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
15893        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
15894      </div>
15895      <div class="table-wrap">
15896        <table id="history-table">
15897          <colgroup>
15898            <col><col><col><col><col><col><col><col><col><col>
15899          </colgroup>
15900          <thead>
15901            <tr id="history-thead">
15902              <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>
15903              <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>
15904              <th>Run ID<div class="col-resize-handle"></div></th>
15905              <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>
15906              <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>
15907              <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>
15908              <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>
15909              <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>
15910              <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>
15911              <th>Report<div class="col-resize-handle"></div></th>
15912            </tr>
15913          </thead>
15914          <tbody id="history-tbody">
15915            {% for entry in entries %}
15916            <tr class="history-row" data-run="{{ entry.run_id }}"
15917                data-timestamp="{{ entry.timestamp }}"
15918                data-project="{{ entry.project_label }}"
15919                data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
15920                data-skipped="{{ entry.files_skipped }}"
15921                data-comments="{{ entry.comment_lines }}"
15922                data-blank="{{ entry.blank_lines }}"
15923                data-branch="{{ entry.git_branch }}"
15924                data-commit="{{ entry.git_commit }}"
15925                data-html-url="/runs/html/{{ entry.run_id }}">
15926              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
15927              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
15928              <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
15929              <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
15930              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
15931              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
15932              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
15933              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
15934              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip" title="{{ entry.git_commit }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">&#8212;</span>{% endif %}</td>
15935              <td class="report-cell">
15936                <div class="actions-cell">
15937                  {% 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 %}
15938                  {% 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 %}
15939                </div>
15940                {% if !entry.submodule_links.is_empty() %}
15941                <details class="submod-details">
15942                  <summary>&#8627; {{ entry.submodule_links.len() }} submodule(s)</summary>
15943                  <div class="submod-link-list">
15944                    {% for sub in entry.submodule_links %}
15945                    <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
15946                    {% endfor %}
15947                  </div>
15948                </details>
15949                {% endif %}
15950              </td>
15951            </tr>
15952            {% endfor %}
15953          </tbody>
15954        </table>
15955      </div>
15956      <div class="pagination">
15957        <span class="pagination-info" id="pagination-info"></span>
15958        <div class="pagination-btns" id="pagination-btns"></div>
15959        <div class="flex-row">
15960          <span class="per-page-label">Show</span>
15961          <select class="per-page" id="per-page-sel">
15962            <option value="10">10 per page</option>
15963            <option value="25" selected>25 per page</option>
15964            <option value="50">50 per page</option>
15965            <option value="100">100 per page</option>
15966          </select>
15967          <span class="per-page-label" id="page-range-label"></span>
15968        </div>
15969      </div>
15970      {% endif %}
15971    </section>
15972  </div>
15973
15974  <footer class="site-footer">
15975    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
15976    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
15977    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
15978    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
15979    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
15980  </footer>
15981
15982  <script nonce="{{ csp_nonce }}">
15983    (function () {
15984      // ── Theme ──────────────────────────────────────────────────────────────
15985      var storageKey = 'oxide-sloc-theme';
15986      var body = document.body;
15987      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
15988      var toggle = document.getElementById('theme-toggle');
15989      if (toggle) toggle.addEventListener('click', function () {
15990        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
15991        body.classList.toggle('dark-theme', next === 'dark');
15992        try { localStorage.setItem(storageKey, next); } catch(e) {}
15993      });
15994
15995      // ── State ─────────────────────────────────────────────────────────────
15996      var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
15997      var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
15998      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
15999
16000      // Aggregate stats from first (most recent) row
16001      if (allRows.length) {
16002        var first = allRows[0];
16003        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 Math.round(v/1e3)+'K';return v.toLocaleString();}
16004        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>':'');}
16005        setChipVal('agg-code', first.dataset.code);
16006        setChipVal('agg-files', first.dataset.files);
16007        setChipVal('agg-skipped', first.dataset.skipped);
16008      }
16009
16010      // ── Branch filter population ──────────────────────────────────────────
16011      (function() {
16012        var branches = {};
16013        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16014        var sel = document.getElementById('branch-filter');
16015        if (sel) Object.keys(branches).sort().forEach(function(b) {
16016          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16017        });
16018      })();
16019
16020      // ── Filter ────────────────────────────────────────────────────────────
16021      function getFilteredRows() {
16022        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16023        var branch = ((document.getElementById('branch-filter') || {}).value || '');
16024        return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
16025          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16026          if (branch && (r.dataset.branch || '') !== branch) return false;
16027          return true;
16028        });
16029      }
16030
16031      // ── Pagination ────────────────────────────────────────────────────────
16032      function renderPage() {
16033        var filtered = getFilteredRows();
16034        var total = filtered.length;
16035        var totalPages = Math.max(1, Math.ceil(total / perPage));
16036        currentPage = Math.min(currentPage, totalPages);
16037        var start = (currentPage - 1) * perPage;
16038        var end = Math.min(start + perPage, total);
16039        var shown = {};
16040        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16041        Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
16042          r.style.display = shown[r.dataset.run] ? '' : 'none';
16043        });
16044        var rl = document.getElementById('page-range-label');
16045        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16046        var info = document.getElementById('pagination-info');
16047        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16048        var btns = document.getElementById('pagination-btns');
16049        if (!btns) return;
16050        btns.innerHTML = '';
16051        function makeBtn(lbl, pg, active, disabled) {
16052          var b = document.createElement('button');
16053          b.className = 'pg-btn' + (active ? ' active' : '');
16054          b.textContent = lbl; b.disabled = disabled;
16055          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16056          return b;
16057        }
16058        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16059        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16060        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16061        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16062      }
16063
16064      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16065      window.applyFilters = function() { currentPage = 1; renderPage(); };
16066
16067      // ── Sorting ───────────────────────────────────────────────────────────
16068      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
16069      function doSort(col, type, order) {
16070        var tbody = document.getElementById('history-tbody');
16071        if (!tbody) return;
16072        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16073        rows.sort(function(a, b) {
16074          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16075          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16076          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16077          return va < vb ? 1 : va > vb ? -1 : 0;
16078        });
16079        rows.forEach(function(r) { tbody.appendChild(r); });
16080        currentPage = 1; renderPage();
16081      }
16082      sortHeaders.forEach(function(th) {
16083        th.addEventListener('click', function(e) {
16084          if (e.target.classList.contains('col-resize-handle')) return;
16085          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16086          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16087          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16088          th.classList.add('sort-' + sortOrder);
16089          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16090          doSort(col, type, sortOrder);
16091        });
16092      });
16093
16094      // ── Column resize ─────────────────────────────────────────────────────
16095      (function() {
16096        var table = document.getElementById('history-table');
16097        if (!table) return;
16098        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16099        var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
16100        ths.forEach(function(th, i) {
16101          var handle = th.querySelector('.col-resize-handle');
16102          if (!handle || !cols[i]) return;
16103          var startX, startW;
16104          handle.addEventListener('mousedown', function(e) {
16105            e.stopPropagation(); e.preventDefault();
16106            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16107            handle.classList.add('dragging');
16108            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16109            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16110            document.addEventListener('mousemove', onMove);
16111            document.addEventListener('mouseup', onUp);
16112          });
16113        });
16114      })();
16115
16116      // ── Reset view ────────────────────────────────────────────────────────
16117      window.resetView = function() {
16118        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16119        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16120        sortCol = null; sortOrder = 'asc';
16121        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16122        var tbody = document.getElementById('history-tbody');
16123        if (tbody) {
16124          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
16125          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16126          rows.forEach(function(r) { tbody.appendChild(r); });
16127        }
16128        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16129        var table = document.getElementById('history-table');
16130        if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
16131        currentPage = 1; renderPage();
16132      };
16133
16134      renderPage();
16135
16136      // ── Export helpers ────────────────────────────────────────────────────
16137      function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
16138      function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
16139      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);}
16140      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;');}
16141      function slocXlsx(fname,sheet,hdrs,rows){
16142        var enc=new TextEncoder();
16143        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;}
16144        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;}
16145        function u2(n){return[n&0xFF,(n>>8)&0xFF];}
16146        function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
16147        function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
16148        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;}
16149        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];}
16150        var rx='<row r="1">';
16151        hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
16152        rx+='</row>';
16153        rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn),num=cell!==''&&cell!=null&&!isNaN(Number(cell))&&isFinite(Number(cell))&&/^[+\-]?\d/.test(String(cell));rx+=num?'<c r="'+ref+'"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});
16154        var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
16155        var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';
16156        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>';
16157        var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><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" applyFont="1"/></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
16158        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/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>',
16159          '_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>',
16160          '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>',
16161          '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>',
16162          'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
16163        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'];
16164        var zparts=[],zcds=[],zoff=0,znf=0;
16165        order.forEach(function(name){
16166          var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
16167          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]);
16168          var entry=new Uint8Array(lha.length+nb.length+sz);
16169          entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
16170          zparts.push(entry);
16171          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));
16172          var cde=new Uint8Array(cda.length+nb.length);
16173          cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
16174          zcds.push(cde);zoff+=entry.length;znf++;
16175        });
16176        var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
16177        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]);
16178        var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
16179        zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
16180        zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
16181        zout.set(new Uint8Array(ea),zpos);
16182        slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
16183      }
16184
16185      var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
16186      function getHistoryRows(){var r=[];document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){r.push([tr.getAttribute('data-timestamp')||'',tr.getAttribute('data-project')||'',tr.getAttribute('data-run')||'',tr.getAttribute('data-files')||'',tr.getAttribute('data-skipped')||'',tr.getAttribute('data-code')||'',tr.getAttribute('data-comments')||'',tr.getAttribute('data-blank')||'',tr.getAttribute('data-branch')||'',tr.getAttribute('data-commit')||'']);});return r;}
16187      window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
16188      window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
16189
16190      var csvBtn = document.getElementById('export-csv-btn');
16191      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
16192      var xlsBtn = document.getElementById('export-xls-btn');
16193      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
16194
16195      // ── Remaining CSP-safe event bindings ────────────────────────────────
16196      (function wireEvents() {
16197        var el;
16198        el = document.getElementById('reset-view-btn');
16199        if (el) el.addEventListener('click', window.resetView);
16200        el = document.getElementById('project-filter');
16201        if (el) el.addEventListener('input', window.applyFilters);
16202        el = document.getElementById('branch-filter');
16203        if (el) el.addEventListener('change', window.applyFilters);
16204        el = document.getElementById('per-page-sel');
16205        if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
16206        el = document.getElementById('add-watched-btn');
16207        if (el) el.addEventListener('click', function() {
16208          fetch('/pick-directory?kind=reports')
16209            .then(function(r) { return r.json(); })
16210            .then(function(data) {
16211              if (!data.cancelled && data.selected_path) {
16212                var form = document.createElement('form');
16213                form.method = 'POST';
16214                form.action = '/watched-dirs/add';
16215                var ri = document.createElement('input');
16216                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16217                var fi = document.createElement('input');
16218                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16219                form.appendChild(ri); form.appendChild(fi);
16220                document.body.appendChild(form);
16221                form.submit();
16222              }
16223            })
16224            .catch(function(e) { alert('Could not open folder picker: ' + e); });
16225        });
16226      })();
16227
16228      (function randomizeWatermarks() {
16229        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16230        if (!wms.length) return;
16231        var placed = [];
16232        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;}
16233        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];}
16234        var half=Math.floor(wms.length/2);
16235        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;});
16236      })();
16237
16238      (function spawnCodeParticles() {
16239        var container = document.getElementById('code-particles');
16240        if (!container) return;
16241        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
16242        for (var i = 0; i < 38; i++) {
16243          (function(idx) {
16244            var el = document.createElement('span');
16245            el.className = 'code-particle';
16246            el.textContent = snippets[idx % snippets.length];
16247            var left = Math.random() * 94 + 2;
16248            var top = Math.random() * 88 + 6;
16249            var dur = (Math.random() * 10 + 9).toFixed(1);
16250            var delay = (Math.random() * 18).toFixed(1);
16251            var rot = (Math.random() * 26 - 13).toFixed(1);
16252            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16253            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';
16254            container.appendChild(el);
16255          })(i);
16256        }
16257      })();
16258    })();
16259  </script>
16260  <script nonce="{{ csp_nonce }}">
16261  (function(){
16262    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'}];
16263    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);});}
16264    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
16265    function init(){
16266      var btn=document.getElementById('settings-btn');if(!btn)return;
16267      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
16268      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
16269      document.body.appendChild(m);
16270      var g=document.getElementById('scheme-grid');
16271      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);});
16272      var cl=document.getElementById('settings-close');
16273      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
16274      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');});
16275      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
16276      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
16277    }
16278    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
16279  }());
16280  </script>
16281</body>
16282</html>
16283"##,
16284    ext = "html"
16285)]
16286struct HistoryTemplate {
16287    version: &'static str,
16288    entries: Vec<HistoryEntryRow>,
16289    total_scans: usize,
16290    linked_count: usize,
16291    browse_error: Option<String>,
16292    watched_dirs: Vec<String>,
16293    csp_nonce: String,
16294}
16295
16296// ── CompareSelectTemplate ──────────────────────────────────────────────────────
16297
16298#[derive(Template)]
16299#[template(
16300    source = r##"
16301<!doctype html>
16302<html lang="en">
16303<head>
16304  <meta charset="utf-8">
16305  <meta name="viewport" content="width=device-width, initial-scale=1">
16306  <title>OxideSLOC | Compare Scans</title>
16307  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16308  <style nonce="{{ csp_nonce }}">
16309    :root {
16310      --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16311      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16312      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16313      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16314      --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
16315    }
16316    body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
16317    *{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);}
16318    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
16319    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
16320    .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);}
16321    .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
16322    .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));}
16323    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
16324    .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;}
16325    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
16326    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
16327    @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; } }
16328    .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;}
16329    .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
16330    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
16331    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
16332    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
16333    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
16334    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
16335    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
16336    .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);}
16337    .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;}
16338    .settings-close:hover{color:var(--text);background:var(--surface-2);}
16339    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
16340    .settings-modal-body{padding:14px 16px 16px;}
16341    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
16342    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
16343    .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;}
16344    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
16345    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
16346    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
16347    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
16348    .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;}
16349    .tz-select:focus{border-color:var(--oxide);}
16350    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
16351    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
16352    .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
16353    .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
16354    .panel-meta{font-size:13px;color:var(--muted);margin:0;}
16355    .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
16356    .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
16357    .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
16358    .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
16359    .per-page-label{font-size:13px;color:var(--muted);}
16360    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;}
16361    .filter-input{min-width:180px;cursor:text;}
16362    .table-wrap{width:100%;overflow-x:auto;}
16363    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
16364    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;}
16365    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
16366    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
16367    #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;}
16368    #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
16369    #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
16370    #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
16371    #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
16372    #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
16373    #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
16374    #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
16375    #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
16376    #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
16377    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
16378    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
16379    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
16380    td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16381    tr:last-child td{border-bottom:none;}
16382    tr.selected td{background:var(--sel-bg);}
16383    tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
16384    tr:hover:not(.selected) td{background:var(--surface-2);}
16385    tr{cursor:pointer;}
16386    .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);}
16387    .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);}
16388    body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
16389    .metric-num{font-weight:700;color:var(--text);}
16390    .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
16391    .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;}
16392    tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
16393    .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;}
16394    .btn:hover{background:var(--line);}
16395    .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
16396    .btn.primary:hover{opacity:.9;}
16397    .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
16398    .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;}
16399    .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
16400    .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
16401    .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
16402    .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
16403    .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
16404    .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;}
16405    .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
16406    .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
16407    .watched-chip-rm:hover{color:var(--oxide);}
16408    .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
16409    .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
16410    .watched-bar-right .btn{box-sizing:border-box;height:28px;}
16411    body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
16412    .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
16413    .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;}
16414    .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;}
16415    .btn-back:hover{background:var(--line);}
16416    .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
16417    .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
16418    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
16419    .pagination-info{font-size:13px;color:var(--muted);}
16420    .pagination-btns{display:flex;gap:6px;}
16421    .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;}
16422    .pg-btn:hover:not(:disabled){background:var(--line);}
16423    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
16424    .pg-btn:disabled{opacity:.35;cursor:default;}
16425    .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
16426    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
16427    .site-footer a{color:var(--muted);}
16428    @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
16429    .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;}
16430    .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;}
16431    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
16432    @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));}}
16433    .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
16434    @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
16435    .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .2s ease,box-shadow .2s ease;}
16436    .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
16437    .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
16438    .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
16439    .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);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 .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
16440    .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
16441    .stat-chip:hover .stat-chip-tip{opacity:1;}
16442    .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;}
16443    .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;}
16444    .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%;}
16445    body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
16446    .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;}
16447    body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
16448    #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
16449    .hidden{display:none!important;}
16450    .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%;}
16451    @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
16452    body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
16453    .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;}
16454    .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
16455    .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
16456    .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;}
16457    .scope-option:hover{background:var(--line);}
16458    .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
16459    body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
16460    .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;}
16461    .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
16462    .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
16463    .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
16464    .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;}
16465  </style>
16466</head>
16467<body>
16468  <div class="background-watermarks" aria-hidden="true">
16469    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16470    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16471    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16472    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16473    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16474    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
16475  </div>
16476  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
16477  <div class="top-nav">
16478    <div class="top-nav-inner">
16479      <a class="brand" href="/">
16480        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
16481        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
16482      </a>
16483      <div class="nav-right">
16484        <a class="nav-pill" href="/">Home</a>
16485        <div class="nav-dropdown">
16486          <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>
16487          <div class="nav-dropdown-menu">
16488            <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>
16489          </div>
16490        </div>
16491        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
16492        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
16493        <div class="nav-dropdown">
16494          <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>
16495          <div class="nav-dropdown-menu">
16496            <a href="/webhook-setup"><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>
16497          </div>
16498        </div>
16499        <div class="server-status-wrap">
16500          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
16501          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
16502        </div>
16503        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
16504          <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>
16505        </button>
16506        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
16507          <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>
16508          <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>
16509        </button>
16510      </div>
16511    </div>
16512  </div>
16513
16514  <div class="page">
16515    <div class="watched-bar">
16516      <div class="watched-bar-left">
16517        <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>
16518        <span class="watched-label">Watched Folders</span>
16519        <div class="watched-chips">
16520          {% for dir in watched_dirs %}
16521          <span class="watched-chip">
16522            <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
16523            <form method="POST" action="/watched-dirs/remove" style="display:contents">
16524              <input type="hidden" name="folder_path" value="{{ dir }}">
16525              <input type="hidden" name="redirect_to" value="/compare-scans">
16526              <button type="submit" class="watched-chip-rm" title="Remove folder">&#x2715;</button>
16527            </form>
16528          </span>
16529          {% endfor %}
16530          {% if watched_dirs.is_empty() %}
16531          <span class="watched-none">No folders watched — click Choose to add one</span>
16532          {% endif %}
16533        </div>
16534      </div>
16535      <div class="watched-bar-right">
16536        <button type="button" class="btn" id="add-watched-btn">
16537          <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>
16538          Choose
16539        </button>
16540        <form method="POST" action="/watched-dirs/refresh" style="display:contents">
16541          <input type="hidden" name="redirect_to" value="/compare-scans">
16542          <button type="submit" class="btn">&#8635; Refresh</button>
16543        </form>
16544      </div>
16545    </div>
16546    {% if total_scans > 0 %}
16547    <div class="summary-strip">
16548      <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>
16549      <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>
16550      <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>
16551      <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>
16552    </div>
16553    {% endif %}
16554    <section class="panel">
16555      <div class="panel-header">
16556        <div>
16557          <h1>Compare Scans</h1>
16558          <p class="panel-meta">{{ total_scans }} scan record(s) available. Select exactly two to compare their metrics side-by-side.</p>
16559        </div>
16560        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
16561          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
16562            <button class="btn primary" id="compare-btn" disabled>
16563              <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>
16564              Compare <span class="sel-count" id="sel-count">0/2</span>
16565            </button>
16566          </div>
16567        </div>
16568      </div>
16569
16570      {% if entries.is_empty() %}
16571      <div class="empty-state">
16572        <strong>No scans yet</strong>
16573        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.
16574      </div>
16575      {% else %}
16576      <div class="filter-row">
16577        <input class="filter-input" id="project-filter" type="text" placeholder="Filter by project…">
16578        <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
16579        <button type="button" class="btn" id="reset-view-btn">&#8635; Reset view</button>
16580      </div>
16581      <div class="scope-panel hidden" id="scope-panel">
16582        <div class="scope-panel-label">
16583          <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>
16584          Compare scope — choose what to include
16585        </div>
16586        <div class="scope-options" id="scope-options"></div>
16587      </div>
16588      {% if total_scans > 0 %}
16589      <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
16590        <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
16591          <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>
16592          Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
16593        </div>
16594      </div>
16595      {% endif %}
16596      <div class="table-wrap">
16597        <table id="compare-table">
16598          <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
16599          <thead>
16600            <tr id="compare-thead">
16601              <th><div class="col-resize-handle"></div></th>
16602              <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>
16603              <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>
16604              <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
16605              <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>
16606              <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>
16607              <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>
16608              <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>
16609              <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>
16610              <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>
16611              <th>Submodules<div class="col-resize-handle"></div></th>
16612            </tr>
16613          </thead>
16614          <tbody id="compare-tbody">
16615            {% for entry in entries %}
16616            <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
16617                data-timestamp="{{ entry.timestamp }}"
16618                data-project="{{ entry.project_label }}"
16619                data-files="{{ entry.files_analyzed }}"
16620                data-code="{{ entry.code_lines }}"
16621                data-comments="{{ entry.comment_lines }}"
16622                data-blank="{{ entry.blank_lines }}"
16623                data-branch="{{ entry.git_branch }}"
16624                data-commit="{{ entry.git_commit }}"
16625                data-submodules="{{ entry.submodule_names_csv }}">
16626              <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
16627              <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
16628              <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
16629              <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
16630              <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
16631              <td><span class="metric-num">{{ entry.code_lines }}</span></td>
16632              <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
16633              <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
16634              <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
16635              <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
16636              <td style="white-space:normal;vertical-align:middle;">{% if !entry.submodule_links.is_empty() %}<div class="submod-chips-cell">{% for sub in entry.submodule_links %}<span class="submod-chip">{{ sub.name }}</span>{% endfor %}</div>{% else %}<span style="color:var(--muted)">&#8212;</span>{% endif %}</td>
16637            </tr>
16638            {% endfor %}
16639          </tbody>
16640        </table>
16641      </div>
16642      <div class="pagination">
16643        <span class="pagination-info" id="pagination-info"></span>
16644        <div class="pagination-btns" id="pagination-btns"></div>
16645        <div class="flex-row">
16646          <span class="per-page-label">Show</span>
16647          <select class="per-page" id="per-page-sel">
16648            <option value="10">10 per page</option>
16649            <option value="25" selected>25 per page</option>
16650            <option value="50">50 per page</option>
16651            <option value="100">100 per page</option>
16652          </select>
16653          <span class="per-page-label" id="page-range-label"></span>
16654        </div>
16655      </div>
16656      {% endif %}
16657    </section>
16658  </div>
16659
16660  <footer class="site-footer">
16661    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
16662    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16663    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16664    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16665    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
16666  </footer>
16667
16668  <script nonce="{{ csp_nonce }}">
16669    (function () {
16670      // ── Theme ──────────────────────────────────────────────────────────────
16671      var storageKey = 'oxide-sloc-theme';
16672      var body = document.body;
16673      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
16674      var toggle = document.getElementById('theme-toggle');
16675      if (toggle) toggle.addEventListener('click', function () {
16676        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
16677        body.classList.toggle('dark-theme', next === 'dark');
16678        try { localStorage.setItem(storageKey, next); } catch(e) {}
16679      });
16680
16681      // ── State ─────────────────────────────────────────────────────────────
16682      var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
16683      var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
16684      allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
16685
16686      // ── Stat chips ────────────────────────────────────────────────────────
16687      (function() {
16688        var projects = {}, latestTs = '', latestRow = null;
16689        allRows.forEach(function(r) {
16690          var p = r.dataset.project || ''; if (p) projects[p] = true;
16691          var ts = r.dataset.timestamp || '';
16692          if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
16693        });
16694        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 Math.round(v/1e3)+'K';return v.toLocaleString();}
16695        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>':'');}
16696        var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
16697        if (latestRow) {
16698          setChipVal('agg-code', latestRow.dataset.code);
16699          setChipVal('agg-files', latestRow.dataset.files);
16700        }
16701      })();
16702
16703      // ── Branch filter population ──────────────────────────────────────────
16704      (function() {
16705        var branches = {};
16706        allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
16707        var sel = document.getElementById('branch-filter');
16708        if (sel) Object.keys(branches).sort().forEach(function(b) {
16709          var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
16710        });
16711      })();
16712
16713      // ── Filter ────────────────────────────────────────────────────────────
16714      function getFilteredRows() {
16715        var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
16716        var branch = ((document.getElementById('branch-filter') || {}).value || '');
16717        return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
16718          if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
16719          if (branch && (r.dataset.branch || '') !== branch) return false;
16720          return true;
16721        });
16722      }
16723
16724      // ── Pagination ────────────────────────────────────────────────────────
16725      function renderPage() {
16726        var filtered = getFilteredRows();
16727        var total = filtered.length;
16728        var totalPages = Math.max(1, Math.ceil(total / perPage));
16729        currentPage = Math.min(currentPage, totalPages);
16730        var start = (currentPage - 1) * perPage;
16731        var end = Math.min(start + perPage, total);
16732        var shown = {};
16733        filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
16734        Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
16735          r.style.display = shown[r.dataset.run] ? '' : 'none';
16736        });
16737        var rl = document.getElementById('page-range-label');
16738        if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
16739        var info = document.getElementById('pagination-info');
16740        if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
16741        var btns = document.getElementById('pagination-btns');
16742        if (!btns) return;
16743        btns.innerHTML = '';
16744        function makeBtn(lbl, pg, active, disabled) {
16745          var b = document.createElement('button');
16746          b.className = 'pg-btn' + (active ? ' active' : '');
16747          b.textContent = lbl; b.disabled = disabled;
16748          if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
16749          return b;
16750        }
16751        btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
16752        var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
16753        for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
16754        btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
16755      }
16756
16757      window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
16758      window.applyFilters = function() { currentPage = 1; renderPage(); };
16759
16760      // ── Sorting ───────────────────────────────────────────────────────────
16761      var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
16762      function doSort(col, type, order) {
16763        var tbody = document.getElementById('compare-tbody');
16764        if (!tbody) return;
16765        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16766        rows.sort(function(a, b) {
16767          var va = a.dataset[col] || '', vb = b.dataset[col] || '';
16768          if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
16769          if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
16770          return va < vb ? 1 : va > vb ? -1 : 0;
16771        });
16772        rows.forEach(function(r) { tbody.appendChild(r); });
16773        currentPage = 1; renderPage();
16774      }
16775      sortHeaders.forEach(function(th) {
16776        th.addEventListener('click', function(e) {
16777          if (e.target.classList.contains('col-resize-handle')) return;
16778          var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
16779          if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
16780          sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16781          th.classList.add('sort-' + sortOrder);
16782          var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
16783          doSort(col, type, sortOrder);
16784        });
16785      });
16786
16787      // Apply default sort (timestamp desc) on initial load
16788      (function() {
16789        var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
16790        if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
16791      })();
16792
16793      // ── Column resize ─────────────────────────────────────────────────────
16794      (function() {
16795        var table = document.getElementById('compare-table');
16796        if (!table) return;
16797        var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
16798        var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
16799        ths.forEach(function(th, i) {
16800          var handle = th.querySelector('.col-resize-handle');
16801          if (!handle || !cols[i]) return;
16802          var startX, startW;
16803          handle.addEventListener('mousedown', function(e) {
16804            e.stopPropagation(); e.preventDefault();
16805            startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
16806            handle.classList.add('dragging');
16807            function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
16808            function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
16809            document.addEventListener('mousemove', onMove);
16810            document.addEventListener('mouseup', onUp);
16811          });
16812        });
16813      })();
16814
16815      // ── Reset view ────────────────────────────────────────────────────────
16816      window.resetView = function() {
16817        var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
16818        var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
16819        sortCol = null; sortOrder = 'asc';
16820        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
16821        var tbody = document.getElementById('compare-tbody');
16822        if (tbody) {
16823          var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
16824          rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
16825          rows.forEach(function(r) { tbody.appendChild(r); });
16826        }
16827        var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
16828        var table = document.getElementById('compare-table');
16829        currentPage = 1; renderPage();
16830        currentPage = 1; renderPage();
16831      };
16832
16833      renderPage();
16834
16835      // ── Row selection state ───────────────────────────────────────────────
16836      var selected = [];
16837      function updateCompareBtn() {
16838        var btn = document.getElementById('compare-btn');
16839        var cnt = document.getElementById('sel-count');
16840        if (!btn) return;
16841        btn.disabled = selected.length !== 2;
16842        if (cnt) cnt.textContent = selected.length + '/2';
16843      }
16844
16845      function toggleRow(row) {
16846        var vid = row.dataset.vid || row.dataset.run;
16847        var idx = selected.indexOf(vid);
16848        if (idx >= 0) {
16849          selected.splice(idx, 1);
16850          row.classList.remove('selected');
16851          var b = document.getElementById('badge-' + vid);
16852          if (b) b.textContent = '';
16853        } else {
16854          if (selected.length >= 2) return;
16855          selected.push(vid);
16856          row.classList.add('selected');
16857        }
16858        selected.forEach(function(v, i) {
16859          var b = document.getElementById('badge-' + v);
16860          if (b) b.textContent = i + 1;
16861        });
16862        updateCompareBtn();
16863        buildScopePanel();
16864      }
16865
16866      // ── Scope panel ───────────────────────────────────────────────────────
16867      var selectedScope = 'all';
16868
16869      function buildScopePanel() {
16870        var panel = document.getElementById('scope-panel');
16871        var opts = document.getElementById('scope-options');
16872        if (!panel || !opts) return;
16873        if (selected.length !== 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16874
16875        // Collect union of submodules from both selected rows.
16876        var allSubs = {};
16877        selected.forEach(function(vid) {
16878          var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
16879          if (!row) return;
16880          (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
16881        });
16882        var subList = Object.keys(allSubs).sort();
16883        if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
16884
16885        panel.classList.remove('hidden');
16886        opts.innerHTML = '';
16887
16888        function makeOption(value, label, title) {
16889          var div = document.createElement('div');
16890          div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
16891          div.dataset.scopeValue = value;
16892          if (title) div.title = title;
16893          var radio = document.createElement('span');
16894          radio.className = 'scope-option-radio';
16895          var lbl = document.createElement('span');
16896          lbl.textContent = label;
16897          div.appendChild(radio);
16898          div.appendChild(lbl);
16899          div.addEventListener('click', function() {
16900            selectedScope = value;
16901            opts.querySelectorAll('.scope-option').forEach(function(o) {
16902              o.classList.toggle('selected', o.dataset.scopeValue === value);
16903            });
16904          });
16905          return div;
16906        }
16907
16908        opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
16909        var sep = document.createElement('span');
16910        sep.className = 'scope-option-sep';
16911        opts.appendChild(sep);
16912        opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
16913        subList.forEach(function(s) {
16914          opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
16915        });
16916      }
16917
16918      function doCompare() {
16919        if (selected.length !== 2) return;
16920        var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
16921        if (selectedScope === 'super') url += '&scope=super';
16922        else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
16923        window.location.href = url;
16924      }
16925
16926      // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
16927      var cbtn = document.getElementById('compare-btn');
16928      if (cbtn) cbtn.addEventListener('click', doCompare);
16929      var pfEl = document.getElementById('project-filter');
16930      if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
16931      var bfEl = document.getElementById('branch-filter');
16932      if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
16933      var rvBtn = document.getElementById('reset-view-btn');
16934      if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
16935      var ppSel = document.getElementById('per-page-sel');
16936      if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
16937
16938      var cmpTbody = document.getElementById('compare-tbody');
16939      if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
16940        var row = e.target.closest('.compare-row');
16941        if (row) toggleRow(row);
16942      });
16943
16944      (function randomizeWatermarks() {
16945        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16946        if (!wms.length) return;
16947        var placed = [];
16948        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;}
16949        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];}
16950        var half=Math.floor(wms.length/2);
16951        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;});
16952      })();
16953
16954      (function spawnCodeParticles() {
16955        var container = document.getElementById('code-particles');
16956        if (!container) return;
16957        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
16958        for (var i = 0; i < 38; i++) {
16959          (function(idx) {
16960            var el = document.createElement('span');
16961            el.className = 'code-particle';
16962            el.textContent = snippets[idx % snippets.length];
16963            var left = Math.random() * 94 + 2;
16964            var top = Math.random() * 88 + 6;
16965            var dur = (Math.random() * 10 + 9).toFixed(1);
16966            var delay = (Math.random() * 18).toFixed(1);
16967            var rot = (Math.random() * 26 - 13).toFixed(1);
16968            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
16969            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';
16970            container.appendChild(el);
16971          })(i);
16972        }
16973      })();
16974
16975      // ── Watched folder picker ─────────────────────────────────────────────
16976      (function() {
16977        var btn = document.getElementById('add-watched-btn');
16978        if (!btn) return;
16979        btn.addEventListener('click', function() {
16980          fetch('/pick-directory?kind=reports')
16981            .then(function(r) { return r.json(); })
16982            .then(function(data) {
16983              if (!data.cancelled && data.selected_path) {
16984                var form = document.createElement('form');
16985                form.method = 'POST';
16986                form.action = '/watched-dirs/add';
16987                var ri = document.createElement('input');
16988                ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
16989                var fi = document.createElement('input');
16990                fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
16991                form.appendChild(ri); form.appendChild(fi);
16992                document.body.appendChild(form);
16993                form.submit();
16994              }
16995            })
16996            .catch(function(e) { alert('Could not open folder picker: ' + e); });
16997        });
16998      })();
16999
17000      // ── Submodule chip truncation ─────────────────────────────────────────
17001      document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
17002        var chips = cell.querySelectorAll('.submod-chip');
17003        var MAX = 4;
17004        if (chips.length <= MAX) return;
17005        for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
17006        var badge = document.createElement('span');
17007        badge.className = 'submod-overflow-badge';
17008        badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
17009        badge.textContent = '+' + (chips.length - MAX) + ' more';
17010        cell.appendChild(badge);
17011        cell.style.maxHeight = 'none';
17012      });
17013    })();
17014  </script>
17015  <script nonce="{{ csp_nonce }}">
17016  (function(){
17017    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'}];
17018    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);});}
17019    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
17020    function init(){
17021      var btn=document.getElementById('settings-btn');if(!btn)return;
17022      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17023      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
17024      document.body.appendChild(m);
17025      var g=document.getElementById('scheme-grid');
17026      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);});
17027      var cl=document.getElementById('settings-close');
17028      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
17029      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');});
17030      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
17031      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
17032    }
17033    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
17034  }());
17035  </script>
17036</body>
17037</html>
17038"##,
17039    ext = "html"
17040)]
17041struct CompareSelectTemplate {
17042    version: &'static str,
17043    entries: Vec<HistoryEntryRow>,
17044    total_scans: usize,
17045    watched_dirs: Vec<String>,
17046    csp_nonce: String,
17047}
17048
17049// ── CompareTemplate ────────────────────────────────────────────────────────────
17050
17051#[derive(Template)]
17052#[template(
17053    source = r##"
17054<!doctype html>
17055<html lang="en">
17056<head>
17057  <meta charset="utf-8">
17058  <meta name="viewport" content="width=device-width, initial-scale=1">
17059  <title>OxideSLOC | Scan Delta</title>
17060  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17061  <style nonce="{{ csp_nonce }}">
17062    :root {
17063      --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
17064      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
17065      --nav:#283790; --nav-2:#013e6b;
17066      --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
17067      --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
17068      --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
17069    }
17070    body.dark-theme {
17071      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
17072      --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
17073    }
17074    *{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);}
17075    .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);}
17076    .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;}
17077    .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));}
17078    .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
17079    .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;}
17080    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
17081    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17082    @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; } }
17083    .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;}
17084    .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
17085    .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
17086    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
17087    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
17088    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
17089    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
17090    .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);}
17091    .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;}
17092    .settings-close:hover{color:var(--text);background:var(--surface-2);}
17093    .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
17094    .settings-modal-body{padding:14px 16px 16px;}
17095    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
17096    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
17097    .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;}
17098    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
17099    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
17100    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
17101    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
17102    .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;}
17103    .tz-select:focus{border-color:var(--oxide);}
17104    .page{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
17105    .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
17106    .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;}
17107    .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
17108    .hero-body{display:block;}
17109    .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;}
17110    .btn-back:hover{background:var(--line);}
17111    h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
17112    h2{margin:0 0 14px;font-size:18px;font-weight:750;}
17113    .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;}
17114    .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
17115    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;}
17116    .muted{color:var(--muted);font-size:14px;}
17117    .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
17118    .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;}
17119    .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
17120    .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
17121    .vpill-arrow{font-size:20px;color:var(--muted);}
17122    .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
17123    .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
17124    .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;}
17125    .delta-card.delta-card-wide{padding:22px 24px;}
17126    .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
17127    body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
17128    .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
17129    .delta-card-from{font-size:15px;color:var(--muted);}
17130    .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
17131    .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
17132    .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
17133    .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%;}
17134    .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;}
17135    .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
17136    .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
17137    .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
17138    .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
17139    body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
17140    body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
17141    .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;}
17142    .meta-card-commit:hover{color:var(--oxide);}
17143    .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
17144    .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
17145    .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
17146    .meta-value{color:var(--text);font-size:13px;}
17147    .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.55;width:230px;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;}
17148    .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);}
17149    .delta-card:hover .dc-tip{display:block;}
17150    .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;}
17151    .export-btn:hover{background:var(--line);}
17152    .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
17153    .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
17154    .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
17155    .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
17156    .delta-card-change.zero{color:var(--muted);background:transparent;}
17157    .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
17158    .delta-card-pct.pos{color:var(--pos);}
17159    .delta-card-pct.neg{color:var(--neg);}
17160    .delta-card-pct.zero{color:var(--muted);}
17161    .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
17162    .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;}
17163    .insight-card.insight-flag{border-color:var(--oxide);}
17164    .insight-card:hover .dc-tip{display:block;}
17165    .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
17166    .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
17167    .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
17168    .insight-label.flag{color:var(--oxide);}
17169    .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
17170    .insight-val.pos{color:var(--pos);}
17171    .insight-val.neg{color:var(--neg);}
17172    .insight-val.high{color:#c0392a;}
17173    .insight-val.med{color:#926000;}
17174    .insight-val.low{color:var(--pos);}
17175    body.dark-theme .insight-val.high{color:#ff6b6b;}
17176    body.dark-theme .insight-val.med{color:#f0c060;}
17177    .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
17178    .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
17179    .fc-row{display:flex;align-items:center;gap:8px;}
17180    .fc-count{font-weight:800;font-size:16px;min-width:28px;}
17181    .fc-label{color:var(--muted);}
17182    .fc-modified .fc-count{color:#926000;}
17183    .fc-added .fc-count{color:var(--pos);}
17184    .fc-removed .fc-count{color:var(--neg);}
17185    .fc-unchanged .fc-count{color:var(--muted);}
17186    body.dark-theme .fc-modified .fc-count{color:#f0c060;}
17187    .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
17188    .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
17189    .chip.modified{background:#fff2d8;color:#926000;}
17190    .chip.added{background:#e8f5ed;color:#1a8f47;}
17191    .chip.removed{background:#fdeaea;color:#b33b3b;}
17192    .chip.unchanged{background:var(--surface-2);color:var(--muted);}
17193    body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
17194    body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
17195    body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
17196    .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
17197    .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
17198    .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;}
17199    .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
17200    .tab-btn:hover:not(.active){background:var(--line);}
17201    .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;}
17202    .btn-reset:hover{background:var(--line);}
17203    .table-wrap{width:100%;overflow-x:auto;}
17204    table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
17205    th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;}
17206    th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
17207    .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
17208    th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
17209    .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
17210    .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
17211    td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
17212    tr:last-child td{border-bottom:none;}
17213    tr.row-added td{background:rgba(26,143,71,0.06);}
17214    tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
17215    tr.row-modified td{background:rgba(146,96,0,0.05);}
17216    tr.row-unchanged td{opacity:.6;}
17217    .file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
17218    .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
17219    .status-badge.added{background:#e8f5ed;color:#1a8f47;}
17220    .status-badge.removed{background:#fdeaea;color:#b33b3b;}
17221    .status-badge.modified{background:#fff2d8;color:#926000;}
17222    .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
17223    body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
17224    body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
17225    body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
17226    .delta-val{font-weight:700;}
17227    .delta-val.pos{color:var(--pos);}
17228    .delta-val.neg{color:var(--neg);}
17229    .delta-val.zero{color:var(--muted);}
17230    .from-to{display:flex;align-items:center;gap:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
17231    .from-to strong{color:var(--text);}
17232    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
17233    .site-footer a{color:var(--muted);}
17234    @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
17235    @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
17236    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
17237    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
17238    .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;}
17239    .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;}
17240    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
17241    @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));}}
17242    .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
17243    .path-link:hover{color:var(--oxide-2);}
17244    .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
17245    a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
17246    a.vpill-id:hover{color:var(--oxide);}
17247    .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
17248    .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
17249    .pagination-info{font-size:13px;color:var(--muted);}
17250    .pagination-btns{display:flex;gap:6px;}
17251    .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;}
17252    .pg-btn:hover:not(:disabled){background:var(--line);}
17253    .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17254    .pg-btn:disabled{opacity:.35;cursor:default;}
17255    .per-page-label{font-size:13px;color:var(--muted);}
17256    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;}
17257    .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17258    .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
17259    .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
17260    .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
17261    .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
17262    .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
17263    .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
17264    .tab-btn.tab-unchanged{color:var(--muted);}
17265    body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
17266    body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
17267    body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
17268    .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;}
17269    .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;}
17270    .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
17271    .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;}
17272    .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
17273    .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;}
17274    .submod-scope-btn:hover{background:var(--line);}
17275    .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
17276    .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
17277    .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
17278    @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
17279    .ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
17280    body.dark-theme .ic-card{background:var(--surface-2);}
17281    .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
17282    .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}
17283    .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
17284    .ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
17285    #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;}
17286  </style>
17287</head>
17288<body>
17289  <div class="background-watermarks" aria-hidden="true">
17290    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17291    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17292    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17293    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17294    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17295    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17296  </div>
17297  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17298  <div class="top-nav">
17299    <div class="top-nav-inner">
17300      <a class="brand" href="/">
17301        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17302        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan delta</div></div>
17303      </a>
17304      <div class="nav-right">
17305        <a class="nav-pill" href="/">Home</a>
17306        <div class="nav-dropdown">
17307          <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>
17308          <div class="nav-dropdown-menu">
17309            <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>
17310          </div>
17311        </div>
17312        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17313        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
17314        <div class="nav-dropdown">
17315          <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>
17316          <div class="nav-dropdown-menu">
17317            <a href="/webhook-setup"><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>
17318          </div>
17319        </div>
17320        <div class="server-status-wrap">
17321          <div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
17322          <div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
17323        </div>
17324        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17325          <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>
17326        </button>
17327        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17328          <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>
17329          <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>
17330        </button>
17331      </div>
17332    </div>
17333  </div>
17334
17335  <div class="page">
17336    <section class="hero">
17337      <div class="hero-header">
17338        <div>
17339          <h1 class="delta-title">Scan Delta</h1>
17340          <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
17341          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
17342            {% if let Some(sub) = active_submodule %}
17343            <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
17344            {% else if super_scope_active %}
17345            <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
17346            {% else %}
17347            <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
17348            {% endif %}
17349            <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
17350          </div>
17351        </div>
17352        <a class="btn-back" href="/compare-scans">
17353          <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>
17354          Compare Scans
17355        </a>
17356      </div>
17357      {% if has_any_submodule_data %}
17358      <div class="submod-scope-bar">
17359        <span class="submod-scope-label">
17360          <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>
17361          Scope:
17362        </span>
17363        <div class="submod-scope-divider"></div>
17364        <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
17365           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}"
17366           title="All files — super-repo and all submodules combined">Full scan</a>
17367        <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
17368           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;scope=super"
17369           title="Only files that are not part of any submodule">Super-repo only</a>
17370        {% for sub in submodule_options %}
17371        <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
17372           href="/compare?a={{ baseline_run_id }}&amp;b={{ current_run_id }}&amp;sub={{ sub }}"
17373           title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
17374        {% endfor %}
17375      </div>
17376      {% endif %}
17377      <div class="hero-body">
17378      <div class="meta-strip">
17379        <div class="delta-card delta-card-meta">
17380          <div class="meta-card-header">
17381            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
17382            <div class="meta-card-project-col">
17383              <div class="meta-card-project">{{ project_name }}</div>
17384              {% if has_any_submodule_data %}
17385              {% if let Some(sub) = active_submodule %}
17386              <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>
17387              {% else if super_scope_active %}
17388              <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>
17389              {% else %}
17390              <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>
17391              {% endif %}
17392              {% endif %}
17393            </div>
17394          </div>
17395          {% if !baseline_git_commit.is_empty() %}
17396          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
17397          {% else %}
17398          <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
17399          {% endif %}
17400          <div class="meta-card-rows">
17401            <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>
17402            <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>
17403            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
17404            <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>
17405            {% if let Some(tags) = baseline_git_tags %}
17406            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17407            {% endif %}
17408          </div>
17409        </div>
17410        <div class="delta-card delta-card-meta">
17411          <div class="meta-card-header">
17412            <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
17413            <div class="meta-card-project-col">
17414              <div class="meta-card-project">{{ project_name }}</div>
17415              {% if has_any_submodule_data %}
17416              {% if let Some(sub) = active_submodule %}
17417              <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>
17418              {% else if super_scope_active %}
17419              <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>
17420              {% else %}
17421              <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>
17422              {% endif %}
17423              {% endif %}
17424            </div>
17425          </div>
17426          {% if !current_git_commit.is_empty() %}
17427          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
17428          {% else %}
17429          <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
17430          {% endif %}
17431          <div class="meta-card-rows">
17432            <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>
17433            <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>
17434            <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value">{{ author }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
17435            <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>
17436            {% if let Some(tags) = current_git_tags %}
17437            <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
17438            {% endif %}
17439          </div>
17440        </div>
17441      </div>
17442      <div class="delta-strip">
17443        <div class="delta-card">
17444          <div class="dc-tip">Executable source lines. Excludes comments and blanks. Positive delta = more code written.</div>
17445          <div class="delta-card-label">Code lines</div>
17446          <div class="delta-card-from">Before: {{ baseline_code }}</div>
17447          <div class="delta-card-to">{{ current_code }}</div>
17448          {% 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>
17449          {% 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>
17450          {% else %}<div class="delta-card-pct zero">±0%</div>
17451          {% endif %}
17452        </div>
17453        <div class="delta-card">
17454          <div class="dc-tip">Source files where language detection succeeded. Changes reflect files added, removed, or reclassified between scans.</div>
17455          <div class="delta-card-label">Files analyzed</div>
17456          <div class="delta-card-from">Before: {{ baseline_files }}</div>
17457          <div class="delta-card-to">{{ current_files }}</div>
17458          {% 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>
17459          {% 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>
17460          {% else %}<div class="delta-card-pct zero">±0%</div>
17461          {% endif %}
17462        </div>
17463        <div class="delta-card">
17464          <div class="dc-tip">Comment-only lines per the active parser policy. A rise indicates more docs; a drop may reflect comment cleanup.</div>
17465          <div class="delta-card-label">Comment lines</div>
17466          <div class="delta-card-from">Before: {{ baseline_comments }}</div>
17467          <div class="delta-card-to">{{ current_comments }}</div>
17468          {% 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>
17469          {% 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>
17470          {% else %}<div class="delta-card-pct zero">±0%</div>
17471          {% endif %}
17472        </div>
17473        <div class="delta-card delta-card-wide">
17474          <div class="dc-tip">Per-file breakdown. Modified = at least one count changed. Unchanged = identical counts in both scans. Added/Removed = only in one scan.</div>
17475          <div class="delta-card-label">File changes</div>
17476          <div class="file-changes-grid">
17477            <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
17478            <div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
17479            <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
17480            <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
17481          </div>
17482        </div>
17483      </div>
17484      <div class="insights-panel">
17485        <div class="insight-card">
17486          <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans. Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
17487          <div class="insight-label">Lines Added</div>
17488          <div class="insight-val pos">+{{ code_lines_added }}</div>
17489          <div class="insight-sub">New or grown source lines</div>
17490        </div>
17491        <div class="insight-card">
17492          <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans. Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
17493          <div class="insight-label">Lines Removed</div>
17494          <div class="insight-val neg">&minus;{{ code_lines_removed }}</div>
17495          <div class="insight-sub">Deleted or shrunk source lines</div>
17496        </div>
17497        <div class="insight-card">
17498          <div class="dc-tip up">Measures total editing activity relative to codebase size. Formula: (lines added + lines removed) ÷ baseline code lines × 100%. Above 20% = high activity, 5–20% = normal velocity, below 5% = stable.</div>
17499          <div class="insight-label">Churn Rate</div>
17500          <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
17501          <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>
17502        </div>
17503        {% if scope_flag %}
17504        <div class="insight-card insight-flag">
17505          <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new. Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline. This often signals a large feature branch, a bulk import, or a generated-file inclusion. Review the file-level delta below to confirm scope.{% endif %}</div>
17506          <div class="insight-label flag">Scope Signal</div>
17507          <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
17508          <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>
17509        </div>
17510        {% endif %}
17511      </div>
17512      </div>
17513    </section>
17514
17515    <section class="panel" id="inline-charts-section">
17516      <h2>Scan Delta Charts</h2>
17517      <div class="ic-grid">
17518        <div class="ic-card">
17519          <div class="ic-card-h2">Code Metrics &mdash; Baseline vs Current</div>
17520          <div class="ic-leg"><span><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded&nbsp;=&nbsp;before)</span></div>
17521          <div id="ic-c1"></div>
17522        </div>
17523        <div class="ic-card" id="ic-lang-card">
17524          <div class="ic-card-h2">Language Code Delta</div>
17525          <div id="ic-c3"></div>
17526        </div>
17527        <div class="ic-card">
17528          <div class="ic-card-h2">Delta by Metric</div>
17529          <div id="ic-c2"></div>
17530        </div>
17531        <div class="ic-card">
17532          <div class="ic-card-h2">File Change Distribution</div>
17533          <div id="ic-c4"></div>
17534        </div>
17535      </div>
17536    </section>
17537
17538    <section class="panel">
17539      <h2>File-level delta</h2>
17540      <div class="filter-tabs-row">
17541        <div class="filter-tabs">
17542          <button class="tab-btn tab-all active" data-filter="all">All</button>
17543          <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
17544          <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
17545          <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
17546          <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
17547        </div>
17548        <div style="display:flex;flex-direction:column;align-items:flex-end;gap:10px;">
17549          <span class="delta-note">* &Delta; = delta (change from baseline &rarr; current)</span>
17550          <div class="export-group">
17551            <button type="button" class="export-btn" id="delta-reset-btn">&#8635; Reset</button>
17552            <button type="button" class="export-btn" id="delta-csv-btn">
17553              <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>
17554              CSV
17555            </button>
17556            <button type="button" class="export-btn" id="delta-xls-btn">
17557              <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>
17558              Excel
17559            </button>
17560            <button type="button" class="export-btn" id="delta-charts-btn">
17561              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="2" y1="20" x2="22" y2="20"/><rect x="3" y="13" width="4" height="7" rx="1"/><rect x="10" y="7" width="4" height="13" rx="1"/><rect x="17" y="2" width="4" height="18" rx="1"/></svg>
17562              Charts
17563            </button>
17564          </div>
17565        </div>
17566      </div>
17567
17568      <div class="table-wrap">
17569      <table id="delta-table">
17570        <colgroup>
17571          <col>
17572          <col>
17573          <col>
17574          <col>
17575          <col>
17576          <col>
17577          <col>
17578        </colgroup>
17579        <thead>
17580          <tr id="delta-thead">
17581            <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>
17582            <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>
17583            <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>
17584            <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>
17585            <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>
17586            <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>
17587            <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>
17588          </tr>
17589        </thead>
17590        <tbody id="delta-tbody">
17591          {% for row in file_rows %}
17592          <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
17593              data-path="{{ row.relative_path }}"
17594              data-language="{{ row.language }}"
17595              data-baseline-code="{{ row.baseline_code }}"
17596              data-current-code="{{ row.current_code }}"
17597              data-code-delta="{{ row.code_delta_str }}"
17598              data-comment-delta="{{ row.comment_delta_str }}"
17599              data-total-delta="{{ row.total_delta_str }}"
17600              data-orig-idx="">
17601            <td class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
17602            <td class="hide-sm">{{ row.language }}</td>
17603            <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
17604            <td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></span></td>
17605            <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
17606            <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
17607            <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
17608          </tr>
17609          {% endfor %}
17610        </tbody>
17611      </table>
17612      </div>
17613      <div class="pagination">
17614        <span class="pagination-info" id="pg-info"></span>
17615        <div class="pagination-btns" id="pg-btns"></div>
17616        <div class="flex-row">
17617          <span class="per-page-label">Show</span>
17618          <select class="per-page" id="per-page-sel">
17619            <option value="10">10 per page</option>
17620            <option value="25" selected>25 per page</option>
17621            <option value="50">50 per page</option>
17622            <option value="100">100 per page</option>
17623          </select>
17624          <span class="per-page-label" id="pg-range-label"></span>
17625        </div>
17626      </div>
17627    </section>
17628  </div>
17629
17630  <div id="ic-tt"></div>
17631
17632  <footer class="site-footer">
17633    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
17634    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17635    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17636    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17637    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
17638  </footer>
17639
17640  <script nonce="{{ csp_nonce }}">
17641    (function () {
17642      var storageKey = 'oxide-sloc-theme';
17643      var body = document.body;
17644      try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
17645      var toggle = document.getElementById('theme-toggle');
17646      if (toggle) toggle.addEventListener('click', function () {
17647        var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
17648        body.classList.toggle('dark-theme', next === 'dark');
17649        try { localStorage.setItem(storageKey, next); } catch(e) {}
17650      });
17651
17652      (function randomizeWatermarks() {
17653        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17654        if (!wms.length) return;
17655        var placed = [];
17656        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;}
17657        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];}
17658        var half=Math.floor(wms.length/2);
17659        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;});
17660      })();
17661
17662      (function spawnCodeParticles() {
17663        var container = document.getElementById('code-particles');
17664        if (!container) return;
17665        var snippets = ['1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','3,841 physical','files: 60','450 comments','cargo build','Ok(run)','Vec<String>','match lang','fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'];
17666        for (var i = 0; i < 38; i++) {
17667          (function(idx) {
17668            var el = document.createElement('span');
17669            el.className = 'code-particle';
17670            el.textContent = snippets[idx % snippets.length];
17671            var left = Math.random() * 94 + 2;
17672            var top = Math.random() * 88 + 6;
17673            var dur = (Math.random() * 10 + 9).toFixed(1);
17674            var delay = (Math.random() * 18).toFixed(1);
17675            var rot = (Math.random() * 26 - 13).toFixed(1);
17676            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
17677            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';
17678            container.appendChild(el);
17679          })(i);
17680        }
17681      })();
17682    })();
17683
17684    var activeStatusFilter = 'all';
17685    var deltaPerPage = 25, deltaCurrPage = 1;
17686
17687    function openFolder(path) {
17688      fetch('/open-path?path=' + encodeURIComponent(path)).catch(function(){});
17689    }
17690
17691    function getDeltaFilteredRows() {
17692      return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
17693        return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
17694      });
17695    }
17696
17697    function renderDeltaPage() {
17698      var filtered = getDeltaFilteredRows();
17699      var total = filtered.length;
17700      var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
17701      deltaCurrPage = Math.min(deltaCurrPage, totalPages);
17702      var start = (deltaCurrPage - 1) * deltaPerPage;
17703      var end = Math.min(start + deltaPerPage, total);
17704      var shownSet = {};
17705      filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
17706      Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
17707        r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
17708      });
17709      var rl = document.getElementById('pg-range-label');
17710      if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
17711      var info = document.getElementById('pg-info');
17712      if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
17713      var btns = document.getElementById('pg-btns');
17714      if (!btns) return;
17715      btns.innerHTML = '';
17716      if (totalPages <= 1) return;
17717      function makeBtn(lbl, pg, active, disabled) {
17718        var b = document.createElement('button');
17719        b.className = 'pg-btn' + (active ? ' active' : '');
17720        b.textContent = lbl; b.disabled = disabled;
17721        if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
17722        return b;
17723      }
17724      btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
17725      var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
17726      for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
17727      btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
17728    }
17729
17730    window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
17731
17732    function filterRows(status, btn) {
17733      activeStatusFilter = status;
17734      deltaCurrPage = 1;
17735      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
17736        b.classList.remove('active');
17737      });
17738      if (btn) btn.classList.add('active');
17739      renderDeltaPage();
17740    }
17741
17742    // ── Sorting ──────────────────────────────────────────────────────────────
17743    var sortCol = null, sortOrder = 'asc';
17744    var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
17745    (function() {
17746      var tbody = document.getElementById('delta-tbody');
17747      if (!tbody) return;
17748      var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17749      rows.forEach(function(r, i) { r.dataset.origIdx = i; });
17750    })();
17751
17752    function parseDeltaNum(str) {
17753      if (!str || str === '—') return 0;
17754      return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
17755    }
17756
17757    sortHeaders.forEach(function(th) {
17758      th.addEventListener('click', function(e) {
17759        if (e.target.classList.contains('col-resize-handle')) return;
17760        var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
17761        if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
17762        sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17763        th.classList.add('sort-' + sortOrder);
17764        var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
17765        var tbody = document.getElementById('delta-tbody');
17766        if (!tbody) return;
17767        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17768        rows.sort(function(a, b) {
17769          var va, vb;
17770          if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
17771          else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
17772          else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
17773          else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
17774          else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17775          else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17776          else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
17777          else { va = ''; vb = ''; }
17778          if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
17779          return va < vb ? 1 : va > vb ? -1 : 0;
17780        });
17781        rows.forEach(function(r) { tbody.appendChild(r); });
17782        deltaCurrPage = 1;
17783        renderDeltaPage();
17784        var activeBtn = document.querySelector('.tab-btn.active');
17785        Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17786        if (activeBtn) activeBtn.classList.add('active');
17787      });
17788    });
17789
17790    // ── Column resize ─────────────────────────────────────────────────────────
17791    (function() {
17792      var table = document.getElementById('delta-table');
17793      if (!table) return;
17794      var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
17795      var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
17796      ths.forEach(function(th, i) {
17797        var handle = th.querySelector('.col-resize-handle');
17798        if (!handle || !cols[i]) return;
17799        var startX, startW;
17800        handle.addEventListener('mousedown', function(e) {
17801          e.stopPropagation(); e.preventDefault();
17802          startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
17803          handle.classList.add('dragging');
17804          function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
17805          function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
17806          document.addEventListener('mousemove', onMove);
17807          document.addEventListener('mouseup', onUp);
17808        });
17809      });
17810    })();
17811
17812    // ── Reset ─────────────────────────────────────────────────────────────────
17813    window.resetDeltaTable = function() {
17814      sortCol = null; sortOrder = 'asc';
17815      sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
17816      var tbody = document.getElementById('delta-tbody');
17817      if (tbody) {
17818        var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
17819        rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
17820        rows.forEach(function(r) { tbody.appendChild(r); });
17821      }
17822      var table = document.getElementById('delta-table');
17823      if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
17824      var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
17825      activeStatusFilter = 'all';
17826      deltaCurrPage = 1;
17827      Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
17828      var allBtn = document.querySelector('.tab-btn');
17829      if (allBtn) allBtn.classList.add('active');
17830      renderDeltaPage();
17831    };
17832
17833    renderDeltaPage();
17834
17835    // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
17836    (function() {
17837      Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
17838        btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
17839      });
17840      var resetBtn = document.getElementById('delta-reset-btn');
17841      if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
17842      var csvBtn = document.getElementById('delta-csv-btn');
17843      if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
17844      var xlsBtn = document.getElementById('delta-xls-btn');
17845      if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
17846      var chartsBtn = document.getElementById('delta-charts-btn');
17847      if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
17848      var ppSel = document.getElementById('per-page-sel');
17849      if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
17850      var pathLink = document.getElementById('project-path-link');
17851      if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
17852    })();
17853
17854    // ── Export helpers ────────────────────────────────────────────────────────
17855    function slocEscXml(v){return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
17856    function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
17857    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);}
17858    function slocMakeXlsx(fname,sd,dr){
17859      var enc=new TextEncoder();
17860      // CRC-32 table
17861      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;}
17862      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;}
17863      function u2(n){return[n&0xFF,(n>>8)&0xFF];}
17864      function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
17865      // Shared string table
17866      var ss=[],si={};
17867      function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
17868      function xe(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
17869      // Worksheet builder — each WS() call gets its own row counter R
17870      function WS(){
17871        var R=0,buf=[];
17872        function cl(c){return String.fromCharCode(65+c);}
17873        function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
17874          '<v>'+S(v)+'</v></c>';}
17875        function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
17876          (st?' s="'+st+'"':'')+'>'+
17877          '<v>'+(+v)+'</v></c>';}
17878        function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
17879        function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17880          '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
17881          '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
17882          '<sheetFormatPr defaultRowHeight="15"/>'+
17883          (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
17884        return{sc:sc,nc:nc,row:row,xml:xml};
17885      }
17886      // Language breakdown
17887      var lm={};
17888      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;});
17889      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
17890      var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
17891      // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
17892      function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
17893      function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
17894      function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
17895      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):'';}
17896      function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
17897      // Summary sheet
17898      var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
17899      r1(s1(0,'OxideSLOC — Scan Delta Report',1));
17900      r1(s1(0,proj,2));
17901      r1(s1(0,sd.bts+' → '+sd.cts,2));
17902      r1('');
17903      r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
17904      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))));
17905      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))));
17906      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))));
17907      r1('');
17908      r1(s1(0,'FILE CHANGES',8));
17909      r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
17910      r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
17911      r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
17912      r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
17913      r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
17914      if(langs.length){
17915        r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
17916        r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
17917        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)));});
17918      }
17919      r1('');r1(s1(0,'SCAN METADATA',8));
17920      r1(s1(1,_blabel)+s1(2,_clabel));
17921      r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
17922      r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
17923      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"/>');
17924      // File Delta sheet
17925      var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
17926      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));
17927      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)));});
17928      var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
17929      // Shared strings XML
17930      var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
17931        '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
17932        ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
17933      // XLSX file map
17934      var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
17935      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>',
17936        '_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>',
17937        '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>',
17938        '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>',
17939        '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>',
17940        'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
17941      // ZIP packer — STORED (no compression), compatible with all XLSX readers
17942      var zparts=[],zcds=[],zoff=0,znf=0;
17943      ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
17944       'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
17945      ].forEach(function(name){
17946        var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
17947        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]);
17948        var entry=new Uint8Array(lha.length+nb.length+sz);
17949        entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
17950        zparts.push(entry);
17951        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));
17952        var cde=new Uint8Array(cda.length+nb.length);
17953        cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
17954        zcds.push(cde);zoff+=entry.length;znf++;
17955      });
17956      var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
17957      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]);
17958      var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
17959      zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
17960      zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
17961      zout.set(new Uint8Array(ea),zpos);
17962      var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
17963      var xurl=URL.createObjectURL(xblob);
17964      var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
17965      document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
17966      setTimeout(function(){URL.revokeObjectURL(xurl);},200);
17967    }
17968    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;');}
17969    var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
17970    function getExportFilename(ext){return _exportBase+'.'+ext;}
17971
17972    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 }}'};
17973    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;}
17974    var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
17975    var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
17976    function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
17977    function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
17978    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):'';}
17979    var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
17980    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)]];}
17981    var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
17982    function getDeltaExportRows(){var r=[];document.querySelectorAll('#delta-tbody .delta-row').forEach(function(tr){var b=parseInt(tr.getAttribute('data-baseline-code'))||0,c=parseInt(tr.getAttribute('data-current-code'))||0,st=tr.getAttribute('data-status')||'';r.push([tr.getAttribute('data-path')||'',tr.getAttribute('data-language')||'',st,tr.getAttribute('data-baseline-code')||'',tr.getAttribute('data-current-code')||'',tr.getAttribute('data-code-delta')||'',tr.getAttribute('data-comment-delta')||'',tr.getAttribute('data-total-delta')||'',_filePct(b,c,st)]);});return r;}
17983    window.exportDeltaCsv = function(){slocCsv(_exportBase+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
17984    window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
17985
17986    // ── Chart HTML report ─────────────────────────────────────────────────────
17987    function slocChartReport(fname, sd, dr) {
17988      var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
17989      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
17990      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
17991      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();}
17992      function px(n){return Math.round(n);}
17993      var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
17994      // Language map
17995      var lm={};
17996      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;});
17997      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
17998
17999      // Builds onmouse* attrs for interactive tooltip on each SVG element
18000      function barTT(label,val){
18001        return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
18002      }
18003
18004      // ── Chart 1: Baseline vs Current grouped bars ────────────────────────
18005      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
18006      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18007      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
18008      var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18009      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18010      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"/>';}
18011      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18012      c1mets.forEach(function(m,i){
18013        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18014        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18015        c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
18016        c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
18017        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
18018        c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
18019        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
18020        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
18021        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
18022      });
18023      c1+='</svg>';
18024
18025      // ── Chart 2: Delta by Metric ─────────────────────────────────────────
18026      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
18027      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18028      var C2W=530,rH=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
18029      var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
18030      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18031      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18032      mets.forEach(function(m,i){
18033        var y=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
18034        var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
18035        var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
18036        c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
18037        c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
18038        if(bw>=52){
18039          c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
18040        }else{
18041          var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
18042          c2+='<text x="'+vx2+'" y="'+(y+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
18043        }
18044      });
18045      c2+='</svg>';
18046
18047      // ── Chart 3: Language Code Delta ─────────────────────────────────────
18048      var c3='';
18049      if(langs.length){
18050        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18051        var C3W=550,c3LW=124,c3FW=52;
18052        var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
18053        var L3rH=30,C3H=langs.length*L3rH+20;
18054        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18055        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18056        langs.forEach(function(l,i){
18057          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
18058          var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
18059          var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18060          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18061          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 • '+e.f+' file'+(e.f!==1?'s':''))+'/>';
18062          if(bw>=48){
18063            c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
18064          }else{
18065            var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
18066            c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
18067          }
18068          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
18069        });
18070        c3+='</svg>';
18071      }
18072
18073      // ── Chart 4: File Change Donut — wider aspect ratio to avoid tall scaling
18074      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;});
18075      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18076      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
18077      var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18078      var ang=-Math.PI/2;
18079      segs.forEach(function(s){
18080        var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18081        var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
18082        var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
18083        var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
18084        var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
18085        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 • '+px(s.v/tot*100)+'%')+'/>';
18086        ang+=sw;
18087      });
18088      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
18089      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18090      segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#333">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
18091      c4+='</svg>';
18092
18093      // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
18094      var ttJs='var tt=document.getElementById("ox-tt");'+
18095        'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
18096        'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
18097        'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
18098        'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
18099        'tt.style.left=x+"px";tt.style.top=y+"px";}'+
18100        'function oxHT(){tt.style.display="none";}';
18101
18102      // body max-width keeps charts from inflating beyond design dimensions on
18103      // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
18104      // each chart's height blows up proportionally, breaking the one-page layout.
18105      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;}'+
18106        'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
18107        '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
18108        'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
18109        '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
18110        '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
18111        'svg{display:block;}'+
18112        '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
18113        '#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;}'+
18114        '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
18115      var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
18116        '<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
18117        '<div id="ox-tt"><\/div>'+
18118        '<h1>OxideSLOC &mdash; Scan Delta Charts<\/h1>'+
18119        '<p class="sub">'+esc(proj)+'&nbsp;&middot;&nbsp;'+esc(sd.bts)+' &rarr; '+esc(sd.cts)+'<\/p>'+
18120        '<div class="two-col">'+
18121        '<div class="card"><h2>Code Metrics &mdash; Baseline vs Current<\/h2>'+
18122        '<div class="leg">'+
18123        '<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
18124        '<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
18125        '<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
18126        '<span style="font-size:10px;color:#888">&nbsp;(faded&nbsp;=&nbsp;before)<\/span><\/div>'+c1+'<\/div>'+
18127        (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
18128        '<\/div>'+
18129        '<div class="two-col">'+
18130        '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
18131        '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
18132        '<\/div>'+
18133        '<script>'+ttJs+'<\/script>'+
18134        '<\/body><\/html>';
18135      slocDownload(html, fname, 'text/html;charset=utf-8;');
18136    }
18137    window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
18138    // ── Inline delta charts ────────────────────────────────────────────────────
18139    var _icTT=document.getElementById('ic-tt');
18140    window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
18141    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';};
18142    window.icHT=function(){if(_icTT)_icTT.style.display='none';};
18143    (function(){
18144      var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
18145      function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
18146      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();}
18147      function px(n){return Math.round(n);}
18148      function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
18149      function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
18150      function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t)icTT(e,t.getAttribute('data-ttl'),t.getAttribute('data-ttv'));});el.addEventListener('mouseout',function(e){if(!e.relatedTarget||!el.contains(e.relatedTarget))icHT();});el.addEventListener('mousemove',function(e){icMT(e);});}
18151      var dr=getDeltaExportRows(),sd=_sd,lm={};
18152      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;});
18153      var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
18154      // Chart 1: Baseline vs Current grouped bars
18155      var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#93C5FD',cc:'#2563EB'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#C4B5FD',cc:'#7C3AED'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#6EE7B7',cc:'#0D9488'}];
18156      var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
18157      var C1W=600,C1H=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,c1gap=10;
18158      var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18159      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"/>';}
18160      c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
18161      c1mets.forEach(function(m,i){
18162        var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
18163        var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
18164        c1+='<text x="'+cx+'" y="14" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
18165        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"/>';
18166        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
18167        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"/>';
18168        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-3)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
18169        c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">Before</text>';
18170        c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">After</text>';
18171      });
18172      c1+='</svg>';
18173      // Chart 2: Delta by Metric
18174      var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#2563EB'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#7C3AED'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#0D9488'}];
18175      var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
18176      var C2W=530,rH=48,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;
18177      var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18178      c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18179      mets.forEach(function(m,i){
18180        var y=14+i*rH,bw=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);
18181        c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
18182        c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
18183        if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
18184        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+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
18185      });
18186      c2+='</svg>';
18187      // Chart 3: Language Code Delta
18188      var c3='';
18189      if(langs.length){
18190        var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
18191        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;
18192        c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
18193        c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
18194        langs.forEach(function(l,i){
18195          var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2),col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
18196          c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
18197          c3+='<rect'+btt(l,'Delta: '+vStr+' code lines • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
18198          if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
18199          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,Calibri,Arial" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
18200          c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
18201        });
18202        c3+='</svg>';
18203      }
18204      // Chart 4: File Change Donut
18205      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;});
18206      var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
18207      var cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210,c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
18208      if(segs.length===1){
18209        // Single segment — SVG arc degenerates at 360°; use concentric circles instead
18210        c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
18211        c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
18212      } else {
18213        segs.forEach(function(s){
18214          var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
18215          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);
18216          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);
18217          c4+='<path'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*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="white" stroke-width="2.5"/>';
18218          ang+=sw;
18219        });
18220      }
18221      c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt(tot)+'</text>';
18222      c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
18223      segs.forEach(function(s,i){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#444">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
18224      c4+='</svg>';
18225      var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
18226      var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
18227      var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}
18228      var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
18229      var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
18230    })();
18231  </script>
18232  <script nonce="{{ csp_nonce }}">
18233  (function(){
18234    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'}];
18235    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);});}
18236    try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
18237    function init(){
18238      var btn=document.getElementById('settings-btn');if(!btn)return;
18239      var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
18240      m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
18241      document.body.appendChild(m);
18242      var g=document.getElementById('scheme-grid');
18243      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);});
18244      var cl=document.getElementById('settings-close');
18245      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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
18246      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');});
18247      if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
18248      document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
18249    }
18250    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
18251  }());
18252  </script>
18253</body>
18254</html>
18255"##,
18256    ext = "html"
18257)]
18258// Template structs need many bool fields to pass Askama rendering flags.
18259#[allow(clippy::struct_excessive_bools)]
18260struct CompareTemplate {
18261    version: &'static str,
18262    project_label: String,
18263    baseline_git_commit: String,
18264    current_git_commit: String,
18265    baseline_run_id: String,
18266    current_run_id: String,
18267    baseline_run_id_short: String,
18268    current_run_id_short: String,
18269    baseline_timestamp: String,
18270    baseline_timestamp_utc_ms: i64,
18271    current_timestamp: String,
18272    current_timestamp_utc_ms: i64,
18273    project_path: String,
18274    baseline_code: u64,
18275    current_code: u64,
18276    code_lines_delta_str: String,
18277    code_lines_delta_class: String,
18278    baseline_files: u64,
18279    current_files: u64,
18280    files_analyzed_delta_str: String,
18281    files_analyzed_delta_class: String,
18282    baseline_comments: u64,
18283    current_comments: u64,
18284    comment_lines_delta_str: String,
18285    comment_lines_delta_class: String,
18286    code_lines_pct_str: String,
18287    files_analyzed_pct_str: String,
18288    comment_lines_pct_str: String,
18289    code_lines_added: i64,
18290    code_lines_removed: i64,
18291    /// True when baseline had 0 code lines — the scope is entirely new in the current scan.
18292    new_scope: bool,
18293    churn_rate_str: String,
18294    churn_rate_class: String,
18295    scope_flag: bool,
18296    files_added: usize,
18297    files_removed: usize,
18298    files_modified: usize,
18299    files_unchanged: usize,
18300    file_rows: Vec<CompareFileDeltaRow>,
18301    baseline_git_author: Option<String>,
18302    current_git_author: Option<String>,
18303    baseline_git_branch: String,
18304    current_git_branch: String,
18305    baseline_git_tags: Option<String>,
18306    current_git_tags: Option<String>,
18307    baseline_git_commit_date: Option<String>,
18308    current_git_commit_date: Option<String>,
18309    project_name: String,
18310    /// Submodule names present in either run (empty when neither scan used submodule breakdown).
18311    submodule_options: Vec<String>,
18312    /// True when either run has submodule data — controls whether the scope bar is shown.
18313    has_any_submodule_data: bool,
18314    /// The submodule currently being compared, if the `sub` query param was provided.
18315    active_submodule: Option<String>,
18316    /// True when `scope=super` is active — viewing super-repo only (no submodule files).
18317    super_scope_active: bool,
18318    csp_nonce: String,
18319}
18320
18321// ── LoginTemplate ──────────────────────────────────────────────────────────────
18322
18323#[derive(Template)]
18324#[template(
18325    source = r##"
18326<!doctype html>
18327<html lang="en">
18328<head>
18329  <meta charset="utf-8">
18330  <meta name="viewport" content="width=device-width, initial-scale=1">
18331  <title>OxideSLOC | Sign In</title>
18332  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18333  <style nonce="{{ csp_nonce }}">
18334    :root {
18335      --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
18336      --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
18337      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
18338      --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
18339    }
18340    *{box-sizing:border-box;}
18341    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);}
18342    .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);}
18343    .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
18344    .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
18345    .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
18346    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18347    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18348    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18349    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
18350    @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));}}
18351    .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
18352    .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
18353    h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
18354    .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
18355    .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;}
18356    label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
18357    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;}
18358    input[type=password]:focus{border-color:var(--oxide);}
18359    .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;}
18360    .btn:hover{opacity:.88;}
18361    .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
18362    code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
18363  </style>
18364</head>
18365<body>
18366  <div class="background-watermarks" aria-hidden="true">
18367    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18368    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18369    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18370    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18371    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18372    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18373    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18374  </div>
18375  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18376<nav class="top-nav">
18377  <a class="brand" href="/">
18378    <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
18379    <span class="brand-title">OxideSLOC</span>
18380  </a>
18381</nav>
18382<main class="page">
18383  <div class="card">
18384    <h1>Sign In</h1>
18385    <p class="subtitle">Enter the API key printed when the server started.</p>
18386    {% if has_error %}
18387    <div class="error">Incorrect API key — please try again.</div>
18388    {% endif %}
18389    <form method="POST" action="/auth/login">
18390      <input type="hidden" name="next" value="{{ next_url|e }}">
18391      <label for="key">API Key</label>
18392      <input id="key" type="password" name="key" autocomplete="current-password"
18393             placeholder="Paste your API key here" autofocus>
18394      <button type="submit" class="btn">Sign In</button>
18395    </form>
18396    <p class="hint">
18397      The API key was printed in the terminal when the server started.<br>
18398      To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
18399      Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
18400    </p>
18401  </div>
18402</main>
18403<script nonce="{{ csp_nonce }}">
18404(function() {
18405  (function randomizeWatermarks() {
18406    var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
18407    if (!wms.length) return;
18408    var placed = [];
18409    function tooClose(top, left) {
18410      for (var i = 0; i < placed.length; i++) {
18411        var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
18412        if (dt < 16 && dl < 12) return true;
18413      }
18414      return false;
18415    }
18416    function pick(leftBand) {
18417      for (var attempt = 0; attempt < 50; attempt++) {
18418        var top = Math.random() * 88 + 2;
18419        var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18420        if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
18421      }
18422      var top = Math.random() * 88 + 2;
18423      var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
18424      placed.push([top, left]); return [top, left];
18425    }
18426    var half = Math.floor(wms.length / 2);
18427    wms.forEach(function (img, i) {
18428      var pos = pick(i < half);
18429      var size = Math.floor(Math.random() * 100 + 120);
18430      var rot = (Math.random() * 360).toFixed(1);
18431      var op = (Math.random() * 0.08 + 0.12).toFixed(2);
18432      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;
18433    });
18434  })();
18435  (function spawnCodeParticles() {
18436    var container = document.getElementById('code-particles');
18437    if (!container) return;
18438    var snippets = [
18439      '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
18440      '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
18441      'git main','#[derive]','impl Scan','3,841 physical','files: 60',
18442      '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
18443      'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
18444    ];
18445    var count = 38;
18446    for (var i = 0; i < count; i++) {
18447      (function(idx) {
18448        var el = document.createElement('span');
18449        el.className = 'code-particle';
18450        el.textContent = snippets[idx % snippets.length];
18451        var left = Math.random() * 94 + 2;
18452        var top = Math.random() * 88 + 6;
18453        var dur = (Math.random() * 10 + 9).toFixed(1);
18454        var delay = (Math.random() * 18).toFixed(1);
18455        var rot = (Math.random() * 26 - 13).toFixed(1);
18456        var op = (Math.random() * 0.09 + 0.06).toFixed(3);
18457        el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
18458        container.appendChild(el);
18459      })(i);
18460    }
18461  })();
18462})();
18463</script>
18464</body>
18465</html>
18466"##,
18467    ext = "html"
18468)]
18469pub(crate) struct LoginTemplate {
18470    pub(crate) csp_nonce: String,
18471    pub(crate) has_error: bool,
18472    pub(crate) next_url: String,
18473    pub(crate) lockout_threshold: u32,
18474}
18475
18476// ── REST API reference page ────────────────────────────────────────────────────
18477
18478#[derive(Template)]
18479#[template(
18480    source = r##"
18481<!doctype html>
18482<html lang="en">
18483<head>
18484  <meta charset="utf-8">
18485  <meta name="viewport" content="width=device-width, initial-scale=1">
18486  <title>OxideSLOC — REST API Reference</title>
18487  <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
18488  <style nonce="{{ csp_nonce }}">
18489    :root {
18490      --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
18491      --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
18492      --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
18493      --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
18494      --success:#16a34a;
18495    }
18496    body.dark-theme {
18497      --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
18498      --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
18499    }
18500    *{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);}
18501    .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);}
18502    .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;}
18503    .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
18504    .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));}
18505    .brand-copy{display:flex;flex-direction:column;justify-content:center;}
18506    .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
18507    .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
18508    .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
18509    @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18510    @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; } }
18511    .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;}
18512    a.nav-pill:hover{background:rgba(255,255,255,0.18);}
18513    .nav-pill.active{background:rgba(255,255,255,0.22);}
18514    .nav-dropdown{position:relative;display:inline-flex;}
18515    .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;}
18516    .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
18517    .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;}
18518    .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;}
18519    .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);}
18520    .nav-dropdown-menu a:last-child{border-bottom:none;}
18521    .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
18522    .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
18523    .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;}
18524    .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
18525    .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
18526    .settings-modal{position:fixed;z-index:9999;background:var(--surface);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;}
18527    .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18528    .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);}
18529    .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
18530    .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
18531    .settings-modal-body{padding:14px 16px 16px;}
18532    .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18533    .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18534    .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;}
18535    .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18536    .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18537    .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18538    .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18539    .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;}
18540    .tz-select:focus{border-color:var(--oxide);}
18541    .page{max-width:960px;margin:0 auto;padding:40px 24px 60px;position:relative;z-index:1;}
18542    .page-header{margin-bottom:28px;}
18543    .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
18544    .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
18545    .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;}
18546    .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
18547    .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
18548    .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
18549    .callout strong{font-weight:800;}
18550    .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;}
18551    body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
18552    .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;}
18553    .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
18554    .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;}
18555    body.dark-theme .base-url-value{color:var(--accent);}
18556    .section{margin-bottom:36px;}
18557    .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);}
18558    .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
18559    .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
18560    .ep-header:hover{background:var(--surface-2);}
18561    .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;}
18562    .method.get{background:#dcfce7;color:#166534;}
18563    .method.post{background:#dbeafe;color:#1e40af;}
18564    .method.delete{background:#fee2e2;color:#991b1b;}
18565    body.dark-theme .method.get{background:#14532d;color:#86efac;}
18566    body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
18567    body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
18568    .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
18569    .ep-path .param{color:var(--oxide-2);}
18570    body.dark-theme .ep-path .param{color:var(--oxide);}
18571    .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;}
18572    .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
18573    .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
18574    .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
18575    body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
18576    body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
18577    body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
18578    .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
18579    .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
18580    .ep-card.open .chevron{transform:rotate(180deg);}
18581    .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
18582    .ep-card.open .ep-body{display:block;}
18583    .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
18584    .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;}
18585    .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
18586    body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
18587    .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18588    table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
18589    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);}
18590    table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
18591    table.params tr:last-child td{border-bottom:none;}
18592    .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
18593    .pt-type{color:var(--muted-2);font-size:12px;}
18594    .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;}
18595    .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;}
18596    body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
18597    body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
18598    details.schema{margin-bottom:14px;}
18599    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;}
18600    details.schema summary:hover{color:var(--text);}
18601    .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;}
18602    .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
18603    .curl-wrap{position:relative;}
18604    .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;}
18605    .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;}
18606    .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
18607    .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
18608    .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
18609    .webhook-note a{color:var(--accent-2);text-decoration:none;}
18610    .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18611    .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
18612    .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
18613    .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
18614    @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));}}
18615    .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18616    .site-footer a{color:var(--muted);}
18617  </style>
18618</head>
18619<body>
18620  <div class="background-watermarks" aria-hidden="true">
18621    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18622    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18623    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18624    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18625    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18626    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18627    <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
18628  </div>
18629  <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18630  <div class="top-nav">
18631    <div class="top-nav-inner">
18632      <a class="brand" href="/">
18633        <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
18634        <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
18635      </a>
18636      <div class="nav-right">
18637        <a class="nav-pill" href="/">Home</a>
18638        <div class="nav-dropdown">
18639          <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>
18640          <div class="nav-dropdown-menu">
18641            <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>
18642          </div>
18643        </div>
18644        <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18645        <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18646        <div class="nav-dropdown">
18647          <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>
18648          <div class="nav-dropdown-menu">
18649            <a href="/webhook-setup"><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>
18650          </div>
18651        </div>
18652        <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18653          <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>
18654        </button>
18655        <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
18656          <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>
18657          <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>
18658        </button>
18659      </div>
18660    </div>
18661  </div>
18662
18663  <div class="page">
18664    <div class="page-header">
18665      <h1 class="page-title">REST API Reference</h1>
18666      <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>
18667    </div>
18668
18669    {% if has_api_key %}
18670    <div class="callout key-set">
18671      <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>
18672      <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>
18673    </div>
18674    {% else %}
18675    <div class="callout no-key">
18676      <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>
18677      <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>
18678    </div>
18679    {% endif %}
18680
18681    <div class="base-url-bar">
18682      <span class="base-url-label">Base URL</span>
18683      <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
18684    </div>
18685
18686    <!-- Health -->
18687    <div class="section">
18688      <h2 class="section-title">Health &amp; Status</h2>
18689      <div class="ep-card">
18690        <div class="ep-header">
18691          <span class="method get">GET</span>
18692          <span class="ep-path">/healthz</span>
18693          <span class="auth-badge public">Public</span>
18694          <span class="ep-desc">Server liveness check</span>
18695          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18696        </div>
18697        <div class="ep-body">
18698          <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>
18699          <p class="params-heading">Response</p>
18700          <div class="schema-block">200 OK
18701Content-Type: text/plain
18702
18703ok</div>
18704          <p class="curl-heading">Example</p>
18705          <div class="curl-wrap">
18706            <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
18707            <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
18708          </div>
18709        </div>
18710      </div>
18711    </div>
18712
18713    <!-- Badges -->
18714    <div class="section">
18715      <h2 class="section-title">Badges</h2>
18716      <div class="ep-card">
18717        <div class="ep-header">
18718          <span class="method get">GET</span>
18719          <span class="ep-path">/badge/<span class="param">{metric}</span></span>
18720          <span class="auth-badge public">Public</span>
18721          <span class="ep-desc">SVG badge for README / dashboard embedding</span>
18722          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18723        </div>
18724        <div class="ep-body">
18725          <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
18726          <p class="params-heading">Path Parameters</p>
18727          <table class="params">
18728            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18729            <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>
18730          </table>
18731          <p class="curl-heading">Example</p>
18732          <div class="curl-wrap">
18733            <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>
18734            <button class="curl-copy-btn" data-target="c-badge">Copy</button>
18735          </div>
18736        </div>
18737      </div>
18738    </div>
18739
18740    <!-- Metrics -->
18741    <div class="section">
18742      <h2 class="section-title">Metrics</h2>
18743
18744      <div class="ep-card">
18745        <div class="ep-header">
18746          <span class="method get">GET</span>
18747          <span class="ep-path">/api/metrics/latest</span>
18748          <span class="auth-badge protected">Protected</span>
18749          <span class="ep-desc">Latest scan metrics (JSON)</span>
18750          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18751        </div>
18752        <div class="ep-body">
18753          <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
18754          <details class="schema"><summary>Response schema</summary>
18755<div class="schema-block">{
18756  "run_id":    string,        // UUID
18757  "timestamp": string,        // ISO-8601 UTC
18758  "project":   string,        // scanned root path
18759  "summary": {
18760    "files_analyzed":       number,
18761    "files_skipped":        number,
18762    "code_lines":           number,
18763    "comment_lines":        number,
18764    "blank_lines":          number,
18765    "total_physical_lines": number,
18766    "functions":            number,
18767    "classes":              number,
18768    "variables":            number,
18769    "imports":              number
18770  },
18771  "languages": [
18772    { "name": string, "files": number, "code_lines": number,
18773      "comment_lines": number, "blank_lines": number,
18774      "functions": number, "classes": number,
18775      "variables": number, "imports": number }
18776  ]
18777}</div></details>
18778          <p class="curl-heading">Example</p>
18779          <div class="curl-wrap">
18780            <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18781  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
18782            <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
18783          </div>
18784        </div>
18785      </div>
18786
18787      <div class="ep-card">
18788        <div class="ep-header">
18789          <span class="method get">GET</span>
18790          <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
18791          <span class="auth-badge protected">Protected</span>
18792          <span class="ep-desc">Metrics for a specific run</span>
18793          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18794        </div>
18795        <div class="ep-body">
18796          <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
18797          <p class="params-heading">Path Parameters</p>
18798          <table class="params">
18799            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18800            <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>
18801          </table>
18802          <p class="curl-heading">Example</p>
18803          <div class="curl-wrap">
18804            <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18805  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/&lt;run_id&gt;</pre>
18806            <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
18807          </div>
18808        </div>
18809      </div>
18810
18811      <div class="ep-card">
18812        <div class="ep-header">
18813          <span class="method get">GET</span>
18814          <span class="ep-path">/api/metrics/history</span>
18815          <span class="auth-badge protected">Protected</span>
18816          <span class="ep-desc">Paginated scan history</span>
18817          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18818        </div>
18819        <div class="ep-body">
18820          <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
18821          <p class="params-heading">Query Parameters</p>
18822          <table class="params">
18823            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18824            <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>
18825            <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>
18826          </table>
18827          <details class="schema"><summary>Response schema</summary>
18828<div class="schema-block">[{
18829  "run_id":         string,
18830  "timestamp":      string,   // ISO-8601 UTC
18831  "commit":         string | null,
18832  "branch":         string | null,
18833  "tags":           string[],
18834  "code_lines":     number,
18835  "comment_lines":  number,
18836  "blank_lines":    number,
18837  "physical_lines": number,
18838  "files_analyzed": number,
18839  "project_label":  string,
18840  "html_url":       string | null
18841}]</div></details>
18842          <p class="curl-heading">Example</p>
18843          <div class="curl-wrap">
18844            <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18845  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
18846            <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
18847          </div>
18848        </div>
18849      </div>
18850
18851      <div class="ep-card">
18852        <div class="ep-header">
18853          <span class="method get">GET</span>
18854          <span class="ep-path">/api/project-history</span>
18855          <span class="auth-badge protected">Protected</span>
18856          <span class="ep-desc">Project-level scan summary</span>
18857          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18858        </div>
18859        <div class="ep-body">
18860          <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>
18861          <p class="params-heading">Query Parameters</p>
18862          <table class="params">
18863            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18864            <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>
18865          </table>
18866          <details class="schema"><summary>Response schema</summary>
18867<div class="schema-block">{
18868  "scan_count":           number,
18869  "last_scan_id":         string | null,
18870  "last_scan_timestamp":  string | null,  // ISO-8601
18871  "last_scan_code_lines": number | null,
18872  "last_git_branch":      string | null,
18873  "last_git_commit":      string | null
18874}</div></details>
18875          <p class="curl-heading">Example</p>
18876          <div class="curl-wrap">
18877            <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18878  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
18879            <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
18880          </div>
18881        </div>
18882      </div>
18883
18884      <div class="ep-card">
18885        <div class="ep-header">
18886          <span class="method get">GET</span>
18887          <span class="ep-path">/api/metrics/submodules</span>
18888          <span class="auth-badge protected">Protected</span>
18889          <span class="ep-desc">List known git submodules across scans</span>
18890          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18891        </div>
18892        <div class="ep-body">
18893          <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>
18894          <p class="params-heading">Query Parameters</p>
18895          <table class="params">
18896            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
18897            <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>
18898          </table>
18899          <details class="schema"><summary>Response schema</summary>
18900<div class="schema-block">[{
18901  "name":          string,  // submodule name
18902  "relative_path": string   // path relative to the project root
18903}]</div></details>
18904          <p class="curl-heading">Example</p>
18905          <div class="curl-wrap">
18906            <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18907  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
18908            <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
18909          </div>
18910        </div>
18911      </div>
18912    </div>
18913
18914    <!-- Async Run Status -->
18915    <div class="section">
18916      <h2 class="section-title">Async Run Status</h2>
18917
18918      <div class="ep-card">
18919        <div class="ep-header">
18920          <span class="method get">GET</span>
18921          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
18922          <span class="auth-badge protected">Protected</span>
18923          <span class="ep-desc">Poll scan completion</span>
18924          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18925        </div>
18926        <div class="ep-body">
18927          <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
18928          <details class="schema"><summary>Response schema</summary>
18929<div class="schema-block">// Running
18930{ "state": "running",  "elapsed_secs": number }
18931
18932// Complete
18933{ "state": "complete", "run_id": string }
18934
18935// Failed
18936{ "state": "failed",   "message": string }</div></details>
18937          <p class="curl-heading">Example</p>
18938          <div class="curl-wrap">
18939            <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18940  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/status</pre>
18941            <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
18942          </div>
18943        </div>
18944      </div>
18945
18946      <div class="ep-card">
18947        <div class="ep-header">
18948          <span class="method get">GET</span>
18949          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
18950          <span class="auth-badge protected">Protected</span>
18951          <span class="ep-desc">Poll PDF generation readiness</span>
18952          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18953        </div>
18954        <div class="ep-body">
18955          <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
18956          <details class="schema"><summary>Response schema</summary>
18957<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
18958          <p class="curl-heading">Example</p>
18959          <div class="curl-wrap">
18960            <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
18961  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/pdf-status</pre>
18962            <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
18963          </div>
18964        </div>
18965      </div>
18966
18967      <div class="ep-card">
18968        <div class="ep-header">
18969          <span class="method post">POST</span>
18970          <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
18971          <span class="auth-badge protected">Protected</span>
18972          <span class="ep-desc">Cancel a running scan</span>
18973          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18974        </div>
18975        <div class="ep-body">
18976          <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>
18977          <p class="curl-heading">Example</p>
18978          <div class="curl-wrap">
18979            <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
18980  -H "Authorization: Bearer $SLOC_API_KEY" \
18981  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/&lt;run_id&gt;/cancel</pre>
18982            <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
18983          </div>
18984        </div>
18985      </div>
18986    </div>
18987
18988    <!-- Scan Profiles -->
18989    <div class="section">
18990      <h2 class="section-title">Scan Profiles</h2>
18991
18992      <div class="ep-card">
18993        <div class="ep-header">
18994          <span class="method get">GET</span>
18995          <span class="ep-path">/api/scan-profiles</span>
18996          <span class="auth-badge protected">Protected</span>
18997          <span class="ep-desc">List saved scan profiles</span>
18998          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
18999        </div>
19000        <div class="ep-body">
19001          <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
19002          <details class="schema"><summary>Response schema</summary>
19003<div class="schema-block">{
19004  "profiles": [{
19005    "id":         string,   // UUID
19006    "name":       string,
19007    "created_at": string,   // ISO-8601
19008    "params":     object
19009  }]
19010}</div></details>
19011          <p class="curl-heading">Example</p>
19012          <div class="curl-wrap">
19013            <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19014  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19015            <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
19016          </div>
19017        </div>
19018      </div>
19019
19020      <div class="ep-card">
19021        <div class="ep-header">
19022          <span class="method post">POST</span>
19023          <span class="ep-path">/api/scan-profiles</span>
19024          <span class="auth-badge protected">Protected</span>
19025          <span class="ep-desc">Save a scan profile</span>
19026          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19027        </div>
19028        <div class="ep-body">
19029          <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
19030          <p class="params-heading">Request Body (application/json)</p>
19031          <table class="params">
19032            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19033            <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>
19034            <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>
19035          </table>
19036          <details class="schema"><summary>Response schema</summary>
19037<div class="schema-block">{ "ok": true }</div></details>
19038          <p class="curl-heading">Example</p>
19039          <div class="curl-wrap">
19040            <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
19041  -H "Authorization: Bearer $SLOC_API_KEY" \
19042  -H "Content-Type: application/json" \
19043  -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
19044  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
19045            <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
19046          </div>
19047        </div>
19048      </div>
19049
19050      <div class="ep-card">
19051        <div class="ep-header">
19052          <span class="method delete">DELETE</span>
19053          <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
19054          <span class="auth-badge protected">Protected</span>
19055          <span class="ep-desc">Delete a scan profile</span>
19056          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19057        </div>
19058        <div class="ep-body">
19059          <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
19060          <p class="params-heading">Path Parameters</p>
19061          <table class="params">
19062            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19063            <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>
19064          </table>
19065          <details class="schema"><summary>Response schema</summary>
19066<div class="schema-block">{ "ok": true }</div></details>
19067          <p class="curl-heading">Example</p>
19068          <div class="curl-wrap">
19069            <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
19070  -H "Authorization: Bearer $SLOC_API_KEY" \
19071  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/&lt;id&gt;</pre>
19072            <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
19073          </div>
19074        </div>
19075      </div>
19076    </div>
19077
19078    <!-- Scheduled Scans -->
19079    <div class="section">
19080      <h2 class="section-title">Scheduled Scans</h2>
19081
19082      <div class="ep-card">
19083        <div class="ep-header">
19084          <span class="method get">GET</span>
19085          <span class="ep-path">/api/schedules</span>
19086          <span class="auth-badge protected">Protected</span>
19087          <span class="ep-desc">List configured schedules</span>
19088          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19089        </div>
19090        <div class="ep-body">
19091          <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
19092          <p class="curl-heading">Example</p>
19093          <div class="curl-wrap">
19094            <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19095  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19096            <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
19097          </div>
19098        </div>
19099      </div>
19100
19101      <div class="ep-card">
19102        <div class="ep-header">
19103          <span class="method post">POST</span>
19104          <span class="ep-path">/api/schedules</span>
19105          <span class="auth-badge protected">Protected</span>
19106          <span class="ep-desc">Create a schedule</span>
19107          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19108        </div>
19109        <div class="ep-body">
19110          <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>
19111          <p class="curl-heading">Example</p>
19112          <div class="curl-wrap">
19113            <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
19114  -H "Authorization: Bearer $SLOC_API_KEY" \
19115  -H "Content-Type: application/json" \
19116  -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
19117  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19118            <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
19119          </div>
19120        </div>
19121      </div>
19122
19123      <div class="ep-card">
19124        <div class="ep-header">
19125          <span class="method delete">DELETE</span>
19126          <span class="ep-path">/api/schedules</span>
19127          <span class="auth-badge protected">Protected</span>
19128          <span class="ep-desc">Delete a schedule</span>
19129          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19130        </div>
19131        <div class="ep-body">
19132          <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
19133          <p class="curl-heading">Example</p>
19134          <div class="curl-wrap">
19135            <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
19136  -H "Authorization: Bearer $SLOC_API_KEY" \
19137  -H "Content-Type: application/json" \
19138  -d '{"id":"&lt;schedule_id&gt;"}' \
19139  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
19140            <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
19141          </div>
19142        </div>
19143      </div>
19144    </div>
19145
19146    <!-- Git Browser -->
19147    <div class="section">
19148      <h2 class="section-title">Git Browser</h2>
19149
19150      <div class="ep-card">
19151        <div class="ep-header">
19152          <span class="method get">GET</span>
19153          <span class="ep-path">/api/git/refs</span>
19154          <span class="auth-badge protected">Protected</span>
19155          <span class="ep-desc">List git refs for a repository</span>
19156          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19157        </div>
19158        <div class="ep-body">
19159          <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
19160          <p class="params-heading">Query Parameters</p>
19161          <table class="params">
19162            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19163            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
19164          </table>
19165          <p class="curl-heading">Example</p>
19166          <div class="curl-wrap">
19167            <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19168  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
19169            <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
19170          </div>
19171        </div>
19172      </div>
19173
19174      <div class="ep-card">
19175        <div class="ep-header">
19176          <span class="method get">GET</span>
19177          <span class="ep-path">/api/git/scan-ref</span>
19178          <span class="auth-badge protected">Protected</span>
19179          <span class="ep-desc">SLOC-scan a specific git ref</span>
19180          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19181        </div>
19182        <div class="ep-body">
19183          <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
19184          <p class="params-heading">Query Parameters</p>
19185          <table class="params">
19186            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19187            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
19188            <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
19189          </table>
19190          <p class="curl-heading">Example</p>
19191          <div class="curl-wrap">
19192            <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19193  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&amp;ref=main"</pre>
19194            <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
19195          </div>
19196        </div>
19197      </div>
19198
19199      <div class="ep-card">
19200        <div class="ep-header">
19201          <span class="method get">GET</span>
19202          <span class="ep-path">/api/git/compare-refs</span>
19203          <span class="auth-badge protected">Protected</span>
19204          <span class="ep-desc">Compare SLOC across two git refs</span>
19205          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19206        </div>
19207        <div class="ep-body">
19208          <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
19209          <p class="params-heading">Query Parameters</p>
19210          <table class="params">
19211            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19212            <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
19213            <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
19214            <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
19215          </table>
19216          <p class="curl-heading">Example</p>
19217          <div class="curl-wrap">
19218            <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19219  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&amp;base=v1.0&amp;head=main"</pre>
19220            <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
19221          </div>
19222        </div>
19223      </div>
19224    </div>
19225
19226    <!-- Webhooks -->
19227    <div class="section">
19228      <h2 class="section-title">Webhooks</h2>
19229      <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>
19230
19231      <div class="ep-card">
19232        <div class="ep-header">
19233          <span class="method post">POST</span>
19234          <span class="ep-path">/webhooks/github</span>
19235          <span class="auth-badge hmac">HMAC</span>
19236          <span class="ep-desc">GitHub push event receiver</span>
19237          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19238        </div>
19239        <div class="ep-body">
19240          <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>
19241          <p class="params-heading">Required Headers</p>
19242          <table class="params">
19243            <tr><th>Header</th><th>Value</th></tr>
19244            <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
19245            <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
19246            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19247          </table>
19248        </div>
19249      </div>
19250
19251      <div class="ep-card">
19252        <div class="ep-header">
19253          <span class="method post">POST</span>
19254          <span class="ep-path">/webhooks/gitlab</span>
19255          <span class="auth-badge hmac">HMAC</span>
19256          <span class="ep-desc">GitLab push event receiver</span>
19257          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19258        </div>
19259        <div class="ep-body">
19260          <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>
19261          <p class="params-heading">Required Headers</p>
19262          <table class="params">
19263            <tr><th>Header</th><th>Value</th></tr>
19264            <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
19265            <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
19266            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19267          </table>
19268        </div>
19269      </div>
19270
19271      <div class="ep-card">
19272        <div class="ep-header">
19273          <span class="method post">POST</span>
19274          <span class="ep-path">/webhooks/bitbucket</span>
19275          <span class="auth-badge hmac">HMAC</span>
19276          <span class="ep-desc">Bitbucket push event receiver</span>
19277          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19278        </div>
19279        <div class="ep-body">
19280          <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
19281          <p class="params-heading">Required Headers</p>
19282          <table class="params">
19283            <tr><th>Header</th><th>Value</th></tr>
19284            <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
19285            <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
19286          </table>
19287        </div>
19288      </div>
19289    </div>
19290
19291    <!-- Config -->
19292    <div class="section">
19293      <h2 class="section-title">Config Import / Export</h2>
19294
19295      <div class="ep-card">
19296        <div class="ep-header">
19297          <span class="method get">GET</span>
19298          <span class="ep-path">/export-config</span>
19299          <span class="auth-badge protected">Protected</span>
19300          <span class="ep-desc">Export server configuration as JSON</span>
19301          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19302        </div>
19303        <div class="ep-body">
19304          <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
19305          <p class="curl-heading">Example</p>
19306          <div class="curl-wrap">
19307            <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19308  -o config.json \
19309  <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
19310            <button class="curl-copy-btn" data-target="c-export">Copy</button>
19311          </div>
19312        </div>
19313      </div>
19314
19315      <div class="ep-card">
19316        <div class="ep-header">
19317          <span class="method post">POST</span>
19318          <span class="ep-path">/import-config</span>
19319          <span class="auth-badge protected">Protected</span>
19320          <span class="ep-desc">Import server configuration</span>
19321          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19322        </div>
19323        <div class="ep-body">
19324          <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
19325          <p class="curl-heading">Example</p>
19326          <div class="curl-wrap">
19327            <pre class="curl-block" data-curl-id="c-import">curl -X POST \
19328  -H "Authorization: Bearer $SLOC_API_KEY" \
19329  -H "Content-Type: application/json" \
19330  -d @config.json \
19331  <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
19332            <button class="curl-copy-btn" data-target="c-import">Copy</button>
19333          </div>
19334        </div>
19335      </div>
19336    </div>
19337
19338    <!-- CI Ingest -->
19339    <div class="section">
19340      <h2 class="section-title">CI Ingest</h2>
19341
19342      <div class="ep-card">
19343        <div class="ep-header">
19344          <span class="method post">POST</span>
19345          <span class="ep-path">/api/ingest</span>
19346          <span class="auth-badge protected">Protected</span>
19347          <span class="ep-desc">Push a pre-computed scan result from CI</span>
19348          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19349        </div>
19350        <div class="ep-body">
19351          <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>
19352          <p class="params-heading">Query Parameters</p>
19353          <table class="params">
19354            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19355            <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>
19356          </table>
19357          <p class="params-heading">Request Body (application/json)</p>
19358          <p style="margin:0 0 8px;font-size:13px;color:var(--muted);">Full <code>AnalysisRun</code> JSON as produced by the CLI <code>--json-out</code> flag.</p>
19359          <details class="schema"><summary>Response schema</summary>
19360<div class="schema-block">// 201 Created
19361{
19362  "run_id":   string,  // UUID of the ingested run
19363  "view_url": string   // relative URL to the report page
19364}</div></details>
19365          <p class="curl-heading">Example</p>
19366          <div class="curl-wrap">
19367            <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
19368  -H "Authorization: Bearer $SLOC_API_KEY" \
19369  -H "Content-Type: application/json" \
19370  -d @result.json \
19371  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
19372            <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
19373          </div>
19374        </div>
19375      </div>
19376    </div>
19377
19378    <!-- Artifact Download -->
19379    <div class="section">
19380      <h2 class="section-title">Artifact Download</h2>
19381
19382      <div class="ep-card">
19383        <div class="ep-header">
19384          <span class="method get">GET</span>
19385          <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
19386          <span class="auth-badge protected">Protected</span>
19387          <span class="ep-desc">Download or view a scan artifact</span>
19388          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19389        </div>
19390        <div class="ep-body">
19391          <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
19392          <p class="params-heading">Path Parameters</p>
19393          <table class="params">
19394            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19395            <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>
19396            <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>
19397          </table>
19398          <p class="params-heading">Query Parameters</p>
19399          <table class="params">
19400            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19401            <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>
19402          </table>
19403          <p class="curl-heading">Example — download JSON result</p>
19404          <div class="curl-wrap">
19405            <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19406  -o result.json \
19407  "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/&lt;run_id&gt;?download=1"</pre>
19408            <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
19409          </div>
19410        </div>
19411      </div>
19412    </div>
19413
19414    <!-- Embed Widget -->
19415    <div class="section">
19416      <h2 class="section-title">Embed Widget</h2>
19417
19418      <div class="ep-card">
19419        <div class="ep-header">
19420          <span class="method get">GET</span>
19421          <span class="ep-path">/embed/summary</span>
19422          <span class="auth-badge protected">Protected</span>
19423          <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
19424          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19425        </div>
19426        <div class="ep-body">
19427          <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>
19428          <p class="params-heading">Query Parameters</p>
19429          <table class="params">
19430            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19431            <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>
19432            <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>
19433          </table>
19434          <p class="curl-heading">Example</p>
19435          <div class="curl-wrap">
19436            <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"
19437        width="460" height="260" style="border:none"&gt;&lt;/iframe&gt;</pre>
19438            <button class="curl-copy-btn" data-target="c-embed">Copy</button>
19439          </div>
19440        </div>
19441      </div>
19442    </div>
19443
19444    <!-- Confluence Integration -->
19445    <div class="section">
19446      <h2 class="section-title">Confluence Integration</h2>
19447
19448      <div class="ep-card">
19449        <div class="ep-header">
19450          <span class="method get">GET</span>
19451          <span class="ep-path">/api/confluence/config</span>
19452          <span class="auth-badge protected">Protected</span>
19453          <span class="ep-desc">Get current Confluence configuration</span>
19454          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19455        </div>
19456        <div class="ep-body">
19457          <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
19458          <details class="schema"><summary>Response schema</summary>
19459<div class="schema-block">{
19460  "configured":     boolean,
19461  "tier":           "cloud" | "server",
19462  "base_url":       string,
19463  "username":       string,
19464  "api_token_set":  boolean,
19465  "space_key":      string,
19466  "parent_page_id": string | null,
19467  "schedule_auto_post": { "&lt;schedule_id&gt;": boolean }
19468}</div></details>
19469          <p class="curl-heading">Example</p>
19470          <div class="curl-wrap">
19471            <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19472  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19473            <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
19474          </div>
19475        </div>
19476      </div>
19477
19478      <div class="ep-card">
19479        <div class="ep-header">
19480          <span class="method post">POST</span>
19481          <span class="ep-path">/api/confluence/config</span>
19482          <span class="auth-badge protected">Protected</span>
19483          <span class="ep-desc">Save Confluence configuration</span>
19484          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19485        </div>
19486        <div class="ep-body">
19487          <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
19488          <p class="params-heading">Request Body (application/json)</p>
19489          <table class="params">
19490            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19491            <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>
19492            <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>
19493            <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>
19494            <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>
19495            <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>
19496            <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>
19497            <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>
19498          </table>
19499          <details class="schema"><summary>Response schema</summary>
19500<div class="schema-block">{ "ok": true }</div></details>
19501          <p class="curl-heading">Example</p>
19502          <div class="curl-wrap">
19503            <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
19504  -H "Authorization: Bearer $SLOC_API_KEY" \
19505  -H "Content-Type: application/json" \
19506  -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
19507  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
19508            <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
19509          </div>
19510        </div>
19511      </div>
19512
19513      <div class="ep-card">
19514        <div class="ep-header">
19515          <span class="method post">POST</span>
19516          <span class="ep-path">/api/confluence/test</span>
19517          <span class="auth-badge protected">Protected</span>
19518          <span class="ep-desc">Test Confluence connection</span>
19519          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19520        </div>
19521        <div class="ep-body">
19522          <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
19523          <details class="schema"><summary>Response schema</summary>
19524<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
19525          <p class="curl-heading">Example</p>
19526          <div class="curl-wrap">
19527            <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
19528  -H "Authorization: Bearer $SLOC_API_KEY" \
19529  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
19530            <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
19531          </div>
19532        </div>
19533      </div>
19534
19535      <div class="ep-card">
19536        <div class="ep-header">
19537          <span class="method post">POST</span>
19538          <span class="ep-path">/api/confluence/post</span>
19539          <span class="auth-badge protected">Protected</span>
19540          <span class="ep-desc">Publish a scan report to Confluence</span>
19541          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19542        </div>
19543        <div class="ep-body">
19544          <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>
19545          <p class="params-heading">Request Body (application/json)</p>
19546          <table class="params">
19547            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19548            <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>
19549            <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>
19550            <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>
19551          </table>
19552          <details class="schema"><summary>Response schema</summary>
19553<div class="schema-block">// 200 OK
19554{ "ok": true, "page_id": string }
19555
19556// 400 / 502 on error
19557{ "ok": false, "error": string }</div></details>
19558          <p class="curl-heading">Example</p>
19559          <div class="curl-wrap">
19560            <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
19561  -H "Authorization: Bearer $SLOC_API_KEY" \
19562  -H "Content-Type: application/json" \
19563  -d '{"run_id":"&lt;uuid&gt;","page_title":"SLOC Report 2025-05-10"}' \
19564  <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
19565            <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
19566          </div>
19567        </div>
19568      </div>
19569
19570      <div class="ep-card">
19571        <div class="ep-header">
19572          <span class="method get">GET</span>
19573          <span class="ep-path">/api/confluence/wiki-markup</span>
19574          <span class="auth-badge protected">Protected</span>
19575          <span class="ep-desc">Get Confluence wiki markup for a run</span>
19576          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19577        </div>
19578        <div class="ep-body">
19579          <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>
19580          <p class="params-heading">Query Parameters</p>
19581          <table class="params">
19582            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19583            <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>
19584          </table>
19585          <p class="curl-heading">Example</p>
19586          <div class="curl-wrap">
19587            <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19588  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=&lt;uuid&gt;"</pre>
19589            <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
19590          </div>
19591        </div>
19592      </div>
19593    </div>
19594
19595    <!-- Authentication -->
19596    <div class="section">
19597      <h2 class="section-title">Authentication</h2>
19598      <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
19599
19600      <div class="ep-card">
19601        <div class="ep-header">
19602          <span class="method get">GET</span>
19603          <span class="ep-path">/auth/login</span>
19604          <span class="auth-badge public">Public</span>
19605          <span class="ep-desc">Login page</span>
19606          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19607        </div>
19608        <div class="ep-body">
19609          <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>
19610          <p class="params-heading">Query Parameters</p>
19611          <table class="params">
19612            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19613            <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>
19614            <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>
19615          </table>
19616        </div>
19617      </div>
19618
19619      <div class="ep-card">
19620        <div class="ep-header">
19621          <span class="method post">POST</span>
19622          <span class="ep-path">/auth/login</span>
19623          <span class="auth-badge public">Public</span>
19624          <span class="ep-desc">Submit credentials and get a session cookie</span>
19625          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19626        </div>
19627        <div class="ep-body">
19628          <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>
19629          <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
19630          <table class="params">
19631            <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
19632            <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>
19633            <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>
19634          </table>
19635          <p class="curl-heading">Example</p>
19636          <div class="curl-wrap">
19637            <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
19638  -d "key=$SLOC_API_KEY&amp;next=/" \
19639  <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
19640            <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
19641          </div>
19642        </div>
19643      </div>
19644    </div>
19645
19646    <!-- Coverage Suggestion -->
19647    <div class="section">
19648      <h2 class="section-title">Coverage Suggestion</h2>
19649
19650      <div class="ep-card">
19651        <div class="ep-header">
19652          <span class="method get">GET</span>
19653          <span class="ep-path">/api/suggest-coverage</span>
19654          <span class="auth-badge protected">Protected</span>
19655          <span class="ep-desc">Auto-detect a coverage file for a project root</span>
19656          <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
19657        </div>
19658        <div class="ep-body">
19659          <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML) and returns the first one found, along with a hint for how to generate it if not present.</p>
19660          <p class="params-heading">Query Parameters</p>
19661          <table class="params">
19662            <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
19663            <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>
19664          </table>
19665          <details class="schema"><summary>Response schema</summary>
19666<div class="schema-block">{
19667  "found": string | null,  // absolute path to the coverage file, if detected
19668  "tool":  string | null,  // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
19669  "hint":  string | null   // shell command to generate coverage if not found
19670}</div></details>
19671          <p class="curl-heading">Example</p>
19672          <div class="curl-wrap">
19673            <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
19674  "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
19675            <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
19676          </div>
19677        </div>
19678      </div>
19679    </div>
19680
19681  </div>
19682
19683  <footer class="site-footer">
19684    oxide-sloc v{{ version }} — local code analysis - metrics, history and reports &nbsp;·&nbsp;
19685    Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
19686    &nbsp;·&nbsp; <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
19687    &nbsp;·&nbsp; <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
19688    &nbsp;·&nbsp; <a href="/api-docs" rel="noopener">REST API</a>
19689  </footer>
19690
19691  <script nonce="{{ csp_nonce }}">
19692    (function () {
19693      var base = window.location.origin;
19694      document.getElementById('base-url').textContent = base;
19695      document.querySelectorAll('.base-url-slot').forEach(function (el) {
19696        el.textContent = base;
19697      });
19698
19699      document.querySelectorAll('.ep-header').forEach(function (hdr) {
19700        hdr.addEventListener('click', function () {
19701          hdr.closest('.ep-card').classList.toggle('open');
19702        });
19703      });
19704
19705      document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
19706        btn.addEventListener('click', function () {
19707          var targetId = btn.dataset.target;
19708          var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
19709          if (!pre) return;
19710          navigator.clipboard.writeText(pre.textContent).then(function () {
19711            btn.textContent = 'Copied!';
19712            btn.classList.add('copied');
19713            setTimeout(function () {
19714              btn.textContent = 'Copy';
19715              btn.classList.remove('copied');
19716            }, 2000);
19717          });
19718        });
19719      });
19720
19721      var storageKey = 'oxide-sloc-theme';
19722      try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
19723      var themeBtn = document.getElementById('theme-toggle');
19724      if (themeBtn) {
19725        themeBtn.addEventListener('click', function () {
19726          var dark = document.body.classList.toggle('dark-theme');
19727          try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
19728        });
19729      }
19730      (function() {
19731        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'}];
19732        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);});}
19733        try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
19734        var btn=document.getElementById('settings-btn');if(!btn)return;
19735        var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
19736        m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
19737        document.body.appendChild(m);
19738        var g=document.getElementById('scheme-grid');
19739        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);});
19740        var cl=document.getElementById('settings-close');
19741        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.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';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:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};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);});};var tzSel=document.getElementById('tz-select');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);
19742        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');});
19743        if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
19744        document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
19745      })();
19746      (function randomizeWatermarks() {
19747        var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
19748        if (!wms.length) return;
19749        var placed = [];
19750        function tooClose(top, left) {
19751          for (var i = 0; i < placed.length; i++) {
19752            var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
19753            if (dt < 16 && dl < 12) return true;
19754          }
19755          return false;
19756        }
19757        function pick(leftBand) {
19758          for (var attempt = 0; attempt < 50; attempt++) {
19759            var top = Math.random() * 88 + 2;
19760            var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19761            if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
19762          }
19763          var top = Math.random() * 88 + 2;
19764          var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
19765          placed.push([top, left]); return [top, left];
19766        }
19767        var half = Math.floor(wms.length / 2);
19768        wms.forEach(function (img, i) {
19769          var pos = pick(i < half);
19770          var size = Math.floor(Math.random() * 100 + 120);
19771          var rot = (Math.random() * 360).toFixed(1);
19772          var op = (Math.random() * 0.08 + 0.12).toFixed(2);
19773          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;
19774        });
19775      })();
19776      (function spawnCodeParticles() {
19777        var container = document.getElementById('code-particles');
19778        if (!container) return;
19779        var snippets = [
19780          '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
19781          '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
19782          'git main','#[derive]','impl Scan','3,841 physical','files: 60',
19783          '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
19784          'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
19785        ];
19786        var count = 38;
19787        for (var i = 0; i < count; i++) {
19788          (function(idx) {
19789            var el = document.createElement('span');
19790            el.className = 'code-particle';
19791            el.textContent = snippets[idx % snippets.length];
19792            var left = Math.random() * 94 + 2;
19793            var top = Math.random() * 88 + 6;
19794            var dur = (Math.random() * 10 + 9).toFixed(1);
19795            var delay = (Math.random() * 18).toFixed(1);
19796            var rot = (Math.random() * 26 - 13).toFixed(1);
19797            var op = (Math.random() * 0.09 + 0.06).toFixed(3);
19798            el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
19799            container.appendChild(el);
19800          })(i);
19801        }
19802      })();
19803    }());
19804  </script>
19805</body>
19806</html>
19807"##,
19808    ext = "html"
19809)]
19810struct ApiDocsTemplate {
19811    has_api_key: bool,
19812    csp_nonce: String,
19813    version: &'static str,
19814}